1156 lines
40 KiB
TypeScript
1156 lines
40 KiB
TypeScript
import { GrowthBook } from '@growthbook/growthbook'
|
||
import { isEqual, memoize } from 'lodash-es'
|
||
import {
|
||
getIsNonInteractiveSession,
|
||
getSessionTrustAccepted,
|
||
} from '../../bootstrap/state.js'
|
||
import { getGrowthBookClientKey } from '../../constants/keys.js'
|
||
import {
|
||
checkHasTrustDialogAccepted,
|
||
getGlobalConfig,
|
||
saveGlobalConfig,
|
||
} from '../../utils/config.js'
|
||
import { logForDebugging } from '../../utils/debug.js'
|
||
import { toError } from '../../utils/errors.js'
|
||
import { getAuthHeaders } from '../../utils/http.js'
|
||
import { logError } from '../../utils/log.js'
|
||
import { createSignal } from '../../utils/signal.js'
|
||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||
import {
|
||
type GitHubActionsMetadata,
|
||
getUserForGrowthBook,
|
||
} from '../../utils/user.js'
|
||
import {
|
||
is1PEventLoggingEnabled,
|
||
logGrowthBookExperimentTo1P,
|
||
} from './firstPartyEventLogger.js'
|
||
|
||
/**
|
||
* User attributes sent to GrowthBook for targeting.
|
||
* Uses UUID suffix (not Uuid) to align with GrowthBook conventions.
|
||
*/
|
||
export type GrowthBookUserAttributes = {
|
||
id: string
|
||
sessionId: string
|
||
deviceID: string
|
||
platform: 'win32' | 'darwin' | 'linux'
|
||
apiBaseUrlHost?: string
|
||
organizationUUID?: string
|
||
accountUUID?: string
|
||
userType?: string
|
||
subscriptionType?: string
|
||
rateLimitTier?: string
|
||
firstTokenTime?: number
|
||
email?: string
|
||
appVersion?: string
|
||
github?: GitHubActionsMetadata
|
||
}
|
||
|
||
/**
|
||
* Malformed feature response from API that uses "value" instead of "defaultValue".
|
||
* This is a workaround until the API is fixed.
|
||
*/
|
||
type MalformedFeatureDefinition = {
|
||
value?: unknown
|
||
defaultValue?: unknown
|
||
[key: string]: unknown
|
||
}
|
||
|
||
let client: GrowthBook | null = null
|
||
|
||
// Named handler refs so resetGrowthBook can remove them to prevent accumulation
|
||
let currentBeforeExitHandler: (() => void) | null = null
|
||
let currentExitHandler: (() => void) | null = null
|
||
|
||
// Track whether auth was available when the client was created
|
||
// This allows us to detect when we need to recreate with fresh auth headers
|
||
let clientCreatedWithAuth = false
|
||
|
||
// Store experiment data from payload for logging exposures later
|
||
type StoredExperimentData = {
|
||
experimentId: string
|
||
variationId: number
|
||
inExperiment?: boolean
|
||
hashAttribute?: string
|
||
hashValue?: string
|
||
}
|
||
const experimentDataByFeature = new Map<string, StoredExperimentData>()
|
||
|
||
// Cache for remote eval feature values - workaround for SDK not respecting remoteEval response
|
||
// The SDK's setForcedFeatures also doesn't work reliably with remoteEval
|
||
const remoteEvalFeatureValues = new Map<string, unknown>()
|
||
|
||
// Track features accessed before init that need exposure logging
|
||
const pendingExposures = new Set<string>()
|
||
|
||
// Track features that have already had their exposure logged this session (dedup)
|
||
// This prevents firing duplicate exposure events when getFeatureValue_CACHED_MAY_BE_STALE
|
||
// is called repeatedly in hot paths (e.g., isAutoMemoryEnabled in render loops)
|
||
const loggedExposures = new Set<string>()
|
||
|
||
// Track re-initialization promise for security gate checks
|
||
// When GrowthBook is re-initializing (e.g., after auth change), security gate checks
|
||
// should wait for init to complete to avoid returning stale values
|
||
let reinitializingPromise: Promise<unknown> | null = null
|
||
|
||
// Listeners notified when GrowthBook feature values refresh (initial init or
|
||
// periodic refresh). Use for systems that bake feature values into long-lived
|
||
// objects at construction time (e.g. firstPartyEventLogger reads
|
||
// tengu_1p_event_batch_config once and builds a LoggerProvider with it) and
|
||
// need to rebuild when config changes. Per-call readers like
|
||
// getEventSamplingConfig / isSinkKilled don't need this — they're already
|
||
// reactive.
|
||
//
|
||
// NOT cleared by resetGrowthBook — subscribers register once (typically in
|
||
// init.ts) and must survive auth-change resets.
|
||
type GrowthBookRefreshListener = () => void | Promise<void>
|
||
const refreshed = createSignal()
|
||
|
||
/** Call a listener with sync-throw and async-rejection both routed to logError. */
|
||
function callSafe(listener: GrowthBookRefreshListener): void {
|
||
try {
|
||
// Promise.resolve() normalizes sync returns and Promises so both
|
||
// sync throws (caught by outer try) and async rejections (caught
|
||
// by .catch) hit logError. Without the .catch, an async listener
|
||
// that rejects becomes an unhandled rejection — the try/catch
|
||
// only sees the Promise, not its eventual rejection.
|
||
void Promise.resolve(listener()).catch(e => {
|
||
logError(e)
|
||
})
|
||
} catch (e) {
|
||
logError(e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Register a callback to fire when GrowthBook feature values refresh.
|
||
* Returns an unsubscribe function.
|
||
*
|
||
* If init has already completed with features by the time this is called
|
||
* (remoteEvalFeatureValues is populated), the listener fires once on the
|
||
* next microtask. This catch-up handles the race where GB's network response
|
||
* lands before the REPL's useEffect commits — on external builds with fast
|
||
* networks and MCP-heavy configs, init can finish in ~100ms while REPL mount
|
||
* takes ~600ms (see #20951 external-build trace at 30.540 vs 31.046).
|
||
*
|
||
* Change detection is on the subscriber: the callback fires on every refresh;
|
||
* use isEqual against your last-seen config to decide whether to act.
|
||
*/
|
||
export function onGrowthBookRefresh(
|
||
listener: GrowthBookRefreshListener,
|
||
): () => void {
|
||
let subscribed = true
|
||
const unsubscribe = refreshed.subscribe(() => callSafe(listener))
|
||
if (remoteEvalFeatureValues.size > 0) {
|
||
queueMicrotask(() => {
|
||
// Re-check: listener may have been removed, or resetGrowthBook may have
|
||
// cleared the Map, between registration and this microtask running.
|
||
if (subscribed && remoteEvalFeatureValues.size > 0) {
|
||
callSafe(listener)
|
||
}
|
||
})
|
||
}
|
||
return () => {
|
||
subscribed = false
|
||
unsubscribe()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse env var overrides for GrowthBook features.
|
||
* Set CLAUDE_INTERNAL_FC_OVERRIDES to a JSON object mapping feature keys to values
|
||
* to bypass remote eval and disk cache. Useful for eval harnesses that need to
|
||
* test specific feature flag configurations. Only active when USER_TYPE is 'ant'.
|
||
*
|
||
* Example: CLAUDE_INTERNAL_FC_OVERRIDES='{"my_feature": true, "my_config": {"key": "val"}}'
|
||
*/
|
||
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).
|
||
* When true, _CACHED_MAY_BE_STALE will return the override without touching
|
||
* disk or network — callers can skip awaiting init for that feature.
|
||
*/
|
||
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). Checked after
|
||
* env-var overrides — env wins so eval harnesses remain deterministic. Unlike
|
||
* getEnvOverrides this is not memoized: the user can change overrides at
|
||
* runtime, and getGlobalConfig() is already memory-cached (pointer-chase)
|
||
* until the next saveGlobalConfig() invalidates it.
|
||
*/
|
||
function getConfigOverrides(): Record<string, unknown> | undefined {
|
||
if (process.env.USER_TYPE !== 'ant') return undefined
|
||
try {
|
||
return getGlobalConfig().growthBookOverrides
|
||
} catch {
|
||
// getGlobalConfig() throws before configReadingAllowed is set (early
|
||
// main.tsx startup path). Same degrade as the disk-cache fallback below.
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Enumerate all known GrowthBook features and their current resolved values
|
||
* (not including overrides). In-memory payload first, disk cache fallback —
|
||
* same priority as the getters. Used by the /config Gates tab.
|
||
*/
|
||
export function getAllGrowthBookFeatures(): Record<string, unknown> {
|
||
if (remoteEvalFeatureValues.size > 0) {
|
||
return Object.fromEntries(remoteEvalFeatureValues)
|
||
}
|
||
return getGlobalConfig().cachedGrowthBookFeatures ?? {}
|
||
}
|
||
|
||
export function getGrowthBookConfigOverrides(): Record<string, unknown> {
|
||
return getConfigOverrides() ?? {}
|
||
}
|
||
|
||
/**
|
||
* Set or clear a single config override. Pass undefined to clear.
|
||
* Fires onGrowthBookRefresh listeners so systems that bake gate values into
|
||
* long-lived objects (useMainLoopModel, useSkillsChange, etc.) rebuild —
|
||
* otherwise overriding e.g. tengu_ant_model_override wouldn't actually
|
||
* change the model until the next periodic refresh.
|
||
*/
|
||
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 } }
|
||
})
|
||
// Subscribers do their own change detection (see onGrowthBookRefresh docs),
|
||
// so firing on a no-op write is fine.
|
||
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)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Log experiment exposure for a feature if it has experiment data.
|
||
* Deduplicates within a session - each feature is logged at most once.
|
||
*/
|
||
function logExposureForFeature(feature: string): void {
|
||
// Skip if already logged this session (dedup)
|
||
if (loggedExposures.has(feature)) {
|
||
return
|
||
}
|
||
|
||
const expData = experimentDataByFeature.get(feature)
|
||
if (expData) {
|
||
loggedExposures.add(feature)
|
||
logGrowthBookExperimentTo1P({
|
||
experimentId: expData.experimentId,
|
||
variationId: expData.variationId,
|
||
userAttributes: getUserAttributes(),
|
||
experimentMetadata: {
|
||
feature_id: feature,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Process a remote eval payload from the GrowthBook server and populate
|
||
* local caches. Called after both initial client.init() and after
|
||
* client.refreshFeatures() so that _BLOCKS_ON_INIT callers see fresh values
|
||
* across the process lifetime, not just init-time snapshots.
|
||
*
|
||
* Without this running on refresh, remoteEvalFeatureValues freezes at its
|
||
* init-time snapshot and getDynamicConfig_BLOCKS_ON_INIT returns stale values
|
||
* for the entire process lifetime — which broke the tengu_max_version_config
|
||
* kill switch for long-running sessions.
|
||
*/
|
||
async function processRemoteEvalPayload(
|
||
gbClient: GrowthBook,
|
||
): Promise<boolean> {
|
||
// WORKAROUND: Transform remote eval response format
|
||
// The API returns { "value": ... } but SDK expects { "defaultValue": ... }
|
||
// TODO: Remove this once the API is fixed to return correct format
|
||
const payload = gbClient.getPayload()
|
||
// Empty object is truthy — without the length check, `{features: {}}`
|
||
// (transient server bug, truncated response) would pass, clear the maps
|
||
// below, return true, and syncRemoteEvalToDisk would wholesale-write `{}`
|
||
// to disk: total flag blackout for every process sharing ~/.claude.json.
|
||
if (!payload?.features || Object.keys(payload.features).length === 0) {
|
||
return false
|
||
}
|
||
|
||
// Clear before rebuild so features removed between refreshes don't
|
||
// leave stale ghost entries that short-circuit getFeatureValueInternal.
|
||
experimentDataByFeature.clear()
|
||
|
||
const transformedFeatures: Record<string, MalformedFeatureDefinition> = {}
|
||
for (const [key, feature] of Object.entries(payload.features)) {
|
||
const f = feature as MalformedFeatureDefinition
|
||
if ('value' in f && !('defaultValue' in f)) {
|
||
transformedFeatures[key] = {
|
||
...f,
|
||
defaultValue: f.value,
|
||
}
|
||
} else {
|
||
transformedFeatures[key] = f
|
||
}
|
||
|
||
// Store experiment data for later logging when feature is accessed
|
||
if (f.source === 'experiment' && f.experimentResult) {
|
||
const expResult = f.experimentResult as {
|
||
variationId?: number
|
||
}
|
||
const exp = f.experiment as { key?: string } | undefined
|
||
if (exp?.key && expResult.variationId !== undefined) {
|
||
experimentDataByFeature.set(key, {
|
||
experimentId: exp.key,
|
||
variationId: expResult.variationId,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
// Re-set the payload with transformed features
|
||
await gbClient.setPayload({
|
||
...payload,
|
||
features: transformedFeatures,
|
||
})
|
||
|
||
// WORKAROUND: Cache the evaluated values directly from remote eval response.
|
||
// The SDK's evalFeature() tries to re-evaluate rules locally, ignoring the
|
||
// pre-evaluated 'value' from remoteEval. setForcedFeatures also doesn't work
|
||
// reliably. So we cache values ourselves and use them in getFeatureValueInternal.
|
||
remoteEvalFeatureValues.clear()
|
||
for (const [key, feature] of Object.entries(transformedFeatures)) {
|
||
// Under remoteEval:true the server pre-evaluates. Whether the answer
|
||
// lands in `value` (current API) or `defaultValue` (post-TODO API shape),
|
||
// it's the authoritative value for this user. Guarding on both keeps
|
||
// syncRemoteEvalToDisk correct across a partial or full API migration.
|
||
const v = 'value' in feature ? feature.value : feature.defaultValue
|
||
if (v !== undefined) {
|
||
remoteEvalFeatureValues.set(key, v)
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Write the complete remoteEvalFeatureValues map to disk. Called exactly
|
||
* once per successful processRemoteEvalPayload — never from a failure path,
|
||
* so init-timeout poisoning is structurally impossible (the .catch() at init
|
||
* never reaches here).
|
||
*
|
||
* Wholesale replace (not merge): features deleted server-side are dropped
|
||
* from disk on the next successful payload. Ant builds ⊇ external, so
|
||
* switching builds is safe — the write is always a complete answer for this
|
||
* process's SDK key.
|
||
*/
|
||
function syncRemoteEvalToDisk(): void {
|
||
const fresh = Object.fromEntries(remoteEvalFeatureValues)
|
||
const config = getGlobalConfig()
|
||
if (isEqual(config.cachedGrowthBookFeatures, fresh)) {
|
||
return
|
||
}
|
||
saveGlobalConfig(current => ({
|
||
...current,
|
||
cachedGrowthBookFeatures: fresh,
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* Check if GrowthBook operations should be enabled
|
||
*/
|
||
function isGrowthBookEnabled(): boolean {
|
||
// GrowthBook depends on 1P event logging.
|
||
return is1PEventLoggingEnabled()
|
||
}
|
||
|
||
/**
|
||
* Hostname of ANTHROPIC_BASE_URL when it points at a non-Anthropic proxy.
|
||
*
|
||
* Enterprise-proxy deployments (Epic, Marble, etc.) typically use
|
||
* apiKeyHelper auth, which means isAnthropicAuthEnabled() returns false and
|
||
* organizationUUID/accountUUID/email are all absent from GrowthBook
|
||
* attributes. Without this, there's no stable attribute to target them on
|
||
* — only per-device IDs. See src/utils/auth.ts isAnthropicAuthEnabled().
|
||
*
|
||
* Returns undefined for unset/default (api.anthropic.com) so the attribute
|
||
* is absent for direct-API users. Hostname only — no path/query/creds.
|
||
*/
|
||
export function getApiBaseUrlHost(): string | undefined {
|
||
const baseUrl = process.env.ANTHROPIC_BASE_URL
|
||
if (!baseUrl) return undefined
|
||
try {
|
||
const host = new URL(baseUrl).host
|
||
if (host === 'api.anthropic.com') return undefined
|
||
return host
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get user attributes for GrowthBook from CoreUserData
|
||
*/
|
||
function getUserAttributes(): GrowthBookUserAttributes {
|
||
const user = getUserForGrowthBook()
|
||
|
||
// For ants, always try to include email from OAuth config even if ANTHROPIC_API_KEY is set.
|
||
// This ensures GrowthBook targeting by email works regardless of auth method.
|
||
let email = user.email
|
||
if (!email && process.env.USER_TYPE === 'ant') {
|
||
email = getGlobalConfig().oauthAccount?.emailAddress
|
||
}
|
||
|
||
const apiBaseUrlHost = getApiBaseUrlHost()
|
||
|
||
const attributes = {
|
||
id: user.deviceId,
|
||
sessionId: user.sessionId,
|
||
deviceID: user.deviceId,
|
||
platform: user.platform,
|
||
...(apiBaseUrlHost && { apiBaseUrlHost }),
|
||
...(user.organizationUuid && { organizationUUID: user.organizationUuid }),
|
||
...(user.accountUuid && { accountUUID: user.accountUuid }),
|
||
...(user.userType && { userType: user.userType }),
|
||
...(user.subscriptionType && { subscriptionType: user.subscriptionType }),
|
||
...(user.rateLimitTier && { rateLimitTier: user.rateLimitTier }),
|
||
...(user.firstTokenTime && { firstTokenTime: user.firstTokenTime }),
|
||
...(email && { email }),
|
||
...(user.appVersion && { appVersion: user.appVersion }),
|
||
...(user.githubActionsMetadata && {
|
||
githubActionsMetadata: user.githubActionsMetadata,
|
||
}),
|
||
}
|
||
return attributes
|
||
}
|
||
|
||
/**
|
||
* Get or create the GrowthBook client instance
|
||
*/
|
||
const getGrowthBookClient = memoize(
|
||
(): { client: GrowthBook; initialized: Promise<void> } | null => {
|
||
if (!isGrowthBookEnabled()) {
|
||
return null
|
||
}
|
||
|
||
const attributes = getUserAttributes()
|
||
const clientKey = getGrowthBookClientKey()
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
logForDebugging(
|
||
`GrowthBook: Creating client with clientKey=${clientKey}, attributes: ${jsonStringify(attributes)}`,
|
||
)
|
||
}
|
||
const baseUrl =
|
||
process.env.USER_TYPE === 'ant'
|
||
? process.env.CLAUDE_CODE_GB_BASE_URL || 'https://api.anthropic.com/'
|
||
: 'https://api.anthropic.com/'
|
||
|
||
// Skip auth if trust hasn't been established yet
|
||
// This prevents executing apiKeyHelper commands before the trust dialog
|
||
// Non-interactive sessions implicitly have workspace trust
|
||
// getSessionTrustAccepted() covers the case where the TrustDialog auto-resolved
|
||
// without persisting trust for the specific CWD (e.g., home directory) —
|
||
// showSetupScreens() sets this after the trust dialog flow completes.
|
||
const hasTrust =
|
||
checkHasTrustDialogAccepted() ||
|
||
getSessionTrustAccepted() ||
|
||
getIsNonInteractiveSession()
|
||
const authHeaders = hasTrust
|
||
? getAuthHeaders()
|
||
: { headers: {}, error: 'trust not established' }
|
||
const hasAuth = !authHeaders.error
|
||
clientCreatedWithAuth = hasAuth
|
||
|
||
// Capture in local variable so the init callback operates on THIS client,
|
||
// not a later client if reinitialization happens before init completes
|
||
const thisClient = new GrowthBook({
|
||
apiHost: baseUrl,
|
||
clientKey,
|
||
attributes,
|
||
remoteEval: true,
|
||
// Re-fetch when user ID or org changes (org change = login to different org)
|
||
cacheKeyAttributes: ['id', 'organizationUUID'],
|
||
// Add auth headers if available
|
||
...(authHeaders.error
|
||
? {}
|
||
: { apiHostRequestHeaders: authHeaders.headers }),
|
||
// Debug logging for Ants
|
||
...(process.env.USER_TYPE === 'ant'
|
||
? {
|
||
log: (msg: string, ctx: Record<string, unknown>) => {
|
||
logForDebugging(`GrowthBook: ${msg} ${jsonStringify(ctx)}`)
|
||
},
|
||
}
|
||
: {}),
|
||
})
|
||
client = thisClient
|
||
|
||
if (!hasAuth) {
|
||
// No auth available yet — skip HTTP init, rely on disk-cached values.
|
||
// initializeGrowthBook() will reset and re-create with auth when available.
|
||
return { client: thisClient, initialized: Promise.resolve() }
|
||
}
|
||
|
||
const initialized = thisClient
|
||
.init({ timeout: 5000 })
|
||
.then(async result => {
|
||
// Guard: if this client was replaced by a newer one, skip processing
|
||
if (client !== thisClient) {
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
logForDebugging(
|
||
'GrowthBook: Skipping init callback for replaced client',
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
logForDebugging(
|
||
`GrowthBook initialized successfully, source: ${result.source}, success: ${result.success}`,
|
||
)
|
||
}
|
||
|
||
const hadFeatures = await processRemoteEvalPayload(thisClient)
|
||
// Re-check: processRemoteEvalPayload yields at `await setPayload`.
|
||
// Microtask-only today (no encryption, no sticky-bucket service), but
|
||
// the guard at the top of this callback runs before that await;
|
||
// this runs after.
|
||
if (client !== thisClient) return
|
||
|
||
if (hadFeatures) {
|
||
for (const feature of pendingExposures) {
|
||
logExposureForFeature(feature)
|
||
}
|
||
pendingExposures.clear()
|
||
syncRemoteEvalToDisk()
|
||
// Notify subscribers: remoteEvalFeatureValues is populated and
|
||
// disk is freshly synced. _CACHED_MAY_BE_STALE reads memory first
|
||
// (#22295), so subscribers see fresh values immediately.
|
||
refreshed.emit()
|
||
}
|
||
|
||
// Log what features were loaded
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
const features = thisClient.getFeatures()
|
||
if (features) {
|
||
const featureKeys = Object.keys(features)
|
||
logForDebugging(
|
||
`GrowthBook loaded ${featureKeys.length} features: ${featureKeys.slice(0, 10).join(', ')}${featureKeys.length > 10 ? '...' : ''}`,
|
||
)
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
logError(toError(error))
|
||
}
|
||
})
|
||
|
||
// Register cleanup handlers for graceful shutdown (named refs so resetGrowthBook can remove them)
|
||
currentBeforeExitHandler = () => client?.destroy()
|
||
currentExitHandler = () => client?.destroy()
|
||
process.on('beforeExit', currentBeforeExitHandler)
|
||
process.on('exit', currentExitHandler)
|
||
|
||
return { client: thisClient, initialized }
|
||
},
|
||
)
|
||
|
||
/**
|
||
* Initialize GrowthBook client (blocks until ready)
|
||
*/
|
||
export const initializeGrowthBook = memoize(
|
||
async (): Promise<GrowthBook | null> => {
|
||
let clientWrapper = getGrowthBookClient()
|
||
if (!clientWrapper) {
|
||
return null
|
||
}
|
||
|
||
// Check if auth has become available since the client was created
|
||
// If so, we need to recreate the client with fresh auth headers
|
||
// Only check if trust is established to avoid triggering apiKeyHelper before trust dialog
|
||
if (!clientCreatedWithAuth) {
|
||
const hasTrust =
|
||
checkHasTrustDialogAccepted() ||
|
||
getSessionTrustAccepted() ||
|
||
getIsNonInteractiveSession()
|
||
if (hasTrust) {
|
||
const currentAuth = getAuthHeaders()
|
||
if (!currentAuth.error) {
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
logForDebugging(
|
||
'GrowthBook: Auth became available after client creation, reinitializing',
|
||
)
|
||
}
|
||
// Use resetGrowthBook to properly destroy old client and stop periodic refresh
|
||
// This prevents double-init where old client's init promise continues running
|
||
resetGrowthBook()
|
||
clientWrapper = getGrowthBookClient()
|
||
if (!clientWrapper) {
|
||
return null
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
await clientWrapper.initialized
|
||
|
||
// Set up periodic refresh after successful initialization
|
||
// This is called here (not separately) so it's always re-established after any reinit
|
||
setupPeriodicGrowthBookRefresh()
|
||
|
||
return clientWrapper.client
|
||
},
|
||
)
|
||
|
||
/**
|
||
* Get a feature value with a default fallback - blocks until initialized.
|
||
* @internal Used by both deprecated and cached functions.
|
||
*/
|
||
async function getFeatureValueInternal<T>(
|
||
feature: string,
|
||
defaultValue: T,
|
||
logExposure: boolean,
|
||
): Promise<T> {
|
||
// Check env var overrides first (for eval harnesses)
|
||
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
|
||
}
|
||
|
||
if (!isGrowthBookEnabled()) {
|
||
return defaultValue
|
||
}
|
||
|
||
const growthBookClient = await initializeGrowthBook()
|
||
if (!growthBookClient) {
|
||
return defaultValue
|
||
}
|
||
|
||
// Use cached remote eval values if available (workaround for SDK bug)
|
||
let result: T
|
||
if (remoteEvalFeatureValues.has(feature)) {
|
||
result = remoteEvalFeatureValues.get(feature) as T
|
||
} else {
|
||
result = growthBookClient.getFeatureValue(feature, defaultValue) as T
|
||
}
|
||
|
||
// Log experiment exposure using stored experiment data
|
||
if (logExposure) {
|
||
logExposureForFeature(feature)
|
||
}
|
||
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
logForDebugging(
|
||
`GrowthBook: getFeatureValue("${feature}") = ${jsonStringify(result)}`,
|
||
)
|
||
}
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* @deprecated Use getFeatureValue_CACHED_MAY_BE_STALE instead, which is non-blocking.
|
||
* This function blocks on GrowthBook initialization which can slow down startup.
|
||
*/
|
||
export async function getFeatureValue_DEPRECATED<T>(
|
||
feature: string,
|
||
defaultValue: T,
|
||
): Promise<T> {
|
||
return getFeatureValueInternal(feature, defaultValue, true)
|
||
}
|
||
|
||
/**
|
||
* Get a feature value from disk cache immediately. Pure read — disk is
|
||
* populated by syncRemoteEvalToDisk on every successful payload (init +
|
||
* periodic refresh), not by this function.
|
||
*
|
||
* This is the preferred method for startup-critical paths and sync contexts.
|
||
* The value may be stale if the cache was written by a previous process.
|
||
*/
|
||
export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
|
||
feature: string,
|
||
defaultValue: T,
|
||
): T {
|
||
// Check env var overrides first (for eval harnesses)
|
||
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
|
||
}
|
||
|
||
if (!isGrowthBookEnabled()) {
|
||
return defaultValue
|
||
}
|
||
|
||
// Log experiment exposure if data is available, otherwise defer until after init
|
||
if (experimentDataByFeature.has(feature)) {
|
||
logExposureForFeature(feature)
|
||
} else {
|
||
pendingExposures.add(feature)
|
||
}
|
||
|
||
// In-memory payload is authoritative once processRemoteEvalPayload has run.
|
||
// Disk is also fresh by then (syncRemoteEvalToDisk runs synchronously inside
|
||
// init), so this is correctness-equivalent to the disk read below — but it
|
||
// skips the config JSON parse and is what onGrowthBookRefresh subscribers
|
||
// depend on to read fresh values the instant they're notified.
|
||
if (remoteEvalFeatureValues.has(feature)) {
|
||
return remoteEvalFeatureValues.get(feature) as T
|
||
}
|
||
|
||
// Fall back to disk cache (survives across process restarts)
|
||
try {
|
||
const cached = getGlobalConfig().cachedGrowthBookFeatures?.[feature]
|
||
return cached !== undefined ? (cached as T) : defaultValue
|
||
} catch {
|
||
return defaultValue
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @deprecated Disk cache is now synced on every successful payload load
|
||
* (init + 20min/6h periodic refresh). The per-feature TTL never fetched
|
||
* fresh data from the server — it only re-wrote in-memory state to disk,
|
||
* which is now redundant. Use getFeatureValue_CACHED_MAY_BE_STALE directly.
|
||
*/
|
||
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 GrowthBook, with fallback to Statsig cache.
|
||
*
|
||
* **MIGRATION ONLY**: This function is for migrating existing Statsig gates to GrowthBook.
|
||
* For new features, use `getFeatureValue_CACHED_MAY_BE_STALE()` instead.
|
||
*
|
||
* - Checks GrowthBook disk cache first
|
||
* - Falls back to Statsig's cachedStatsigGates during migration
|
||
* - The value may be stale if the cache hasn't been updated recently
|
||
*
|
||
* @deprecated Use getFeatureValue_CACHED_MAY_BE_STALE() for new code. This function
|
||
* exists only to support migration of existing Statsig gates.
|
||
*/
|
||
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
|
||
gate: string,
|
||
): boolean {
|
||
// Check env var overrides first (for eval harnesses)
|
||
const overrides = getEnvOverrides()
|
||
if (overrides && gate in overrides) {
|
||
return Boolean(overrides[gate])
|
||
}
|
||
const configOverrides = getConfigOverrides()
|
||
if (configOverrides && gate in configOverrides) {
|
||
return Boolean(configOverrides[gate])
|
||
}
|
||
|
||
if (!isGrowthBookEnabled()) {
|
||
return false
|
||
}
|
||
|
||
// Log experiment exposure if data is available, otherwise defer until after init
|
||
if (experimentDataByFeature.has(gate)) {
|
||
logExposureForFeature(gate)
|
||
} else {
|
||
pendingExposures.add(gate)
|
||
}
|
||
|
||
// Return cached value immediately from disk
|
||
// First check GrowthBook cache, then fall back to Statsig cache for migration
|
||
const config = getGlobalConfig()
|
||
const gbCached = config.cachedGrowthBookFeatures?.[gate]
|
||
if (gbCached !== undefined) {
|
||
return Boolean(gbCached)
|
||
}
|
||
// Fallback to Statsig cache for migration period
|
||
return config.cachedStatsigGates?.[gate] ?? false
|
||
}
|
||
|
||
/**
|
||
* Check a security restriction gate, waiting for re-init if in progress.
|
||
*
|
||
* Use this for security-critical gates where we need fresh values after auth changes.
|
||
*
|
||
* Behavior:
|
||
* - If GrowthBook is re-initializing (e.g., after login), waits for it to complete
|
||
* - Otherwise, returns cached value immediately (Statsig cache first, then GrowthBook)
|
||
*
|
||
* Statsig cache is checked first as a safety measure for security-related checks:
|
||
* if the Statsig cache indicates the gate is enabled, we honor it.
|
||
*/
|
||
export async function checkSecurityRestrictionGate(
|
||
gate: string,
|
||
): Promise<boolean> {
|
||
// Check env var overrides first (for eval harnesses)
|
||
const overrides = getEnvOverrides()
|
||
if (overrides && gate in overrides) {
|
||
return Boolean(overrides[gate])
|
||
}
|
||
const configOverrides = getConfigOverrides()
|
||
if (configOverrides && gate in configOverrides) {
|
||
return Boolean(configOverrides[gate])
|
||
}
|
||
|
||
if (!isGrowthBookEnabled()) {
|
||
return false
|
||
}
|
||
|
||
// If re-initialization is in progress, wait for it to complete
|
||
// This ensures we get fresh values after auth changes
|
||
if (reinitializingPromise) {
|
||
await reinitializingPromise
|
||
}
|
||
|
||
// Check Statsig cache first - it may have correct value from previous logged-in session
|
||
const config = getGlobalConfig()
|
||
const statsigCached = config.cachedStatsigGates?.[gate]
|
||
if (statsigCached !== undefined) {
|
||
return Boolean(statsigCached)
|
||
}
|
||
|
||
// Then check GrowthBook cache
|
||
const gbCached = config.cachedGrowthBookFeatures?.[gate]
|
||
if (gbCached !== undefined) {
|
||
return Boolean(gbCached)
|
||
}
|
||
|
||
// No cache - return false (don't block on init for uncached gates)
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Check a boolean entitlement gate with fallback-to-blocking semantics.
|
||
*
|
||
* Fast path: if the disk cache already says `true`, return it immediately.
|
||
* Slow path: if disk says `false`/missing, await GrowthBook init and fetch the
|
||
* fresh server value (max ~5s). Disk is populated by syncRemoteEvalToDisk
|
||
* inside init, so by the time the slow path returns, disk already has the
|
||
* fresh value — no write needed here.
|
||
*
|
||
* Use for user-invoked features (e.g. /remote-control) that are gated on
|
||
* subscription/org, where a stale `false` would unfairly block access but a
|
||
* stale `true` is acceptable (the server is the real gatekeeper).
|
||
*/
|
||
export async function checkGate_CACHED_OR_BLOCKING(
|
||
gate: string,
|
||
): Promise<boolean> {
|
||
// Check env var overrides first (for eval harnesses)
|
||
const overrides = getEnvOverrides()
|
||
if (overrides && gate in overrides) {
|
||
return Boolean(overrides[gate])
|
||
}
|
||
const configOverrides = getConfigOverrides()
|
||
if (configOverrides && gate in configOverrides) {
|
||
return Boolean(configOverrides[gate])
|
||
}
|
||
|
||
if (!isGrowthBookEnabled()) {
|
||
return false
|
||
}
|
||
|
||
// Fast path: disk cache already says true — trust it
|
||
const cached = getGlobalConfig().cachedGrowthBookFeatures?.[gate]
|
||
if (cached === true) {
|
||
// Log experiment exposure if data is available, otherwise defer
|
||
if (experimentDataByFeature.has(gate)) {
|
||
logExposureForFeature(gate)
|
||
} else {
|
||
pendingExposures.add(gate)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Slow path: disk says false/missing — may be stale, fetch fresh
|
||
return getFeatureValueInternal(gate, false, true)
|
||
}
|
||
|
||
/**
|
||
* Refresh GrowthBook after auth changes (login/logout).
|
||
*
|
||
* NOTE: This must destroy and recreate the client because GrowthBook's
|
||
* apiHostRequestHeaders cannot be updated after client creation.
|
||
*/
|
||
export function refreshGrowthBookAfterAuthChange(): void {
|
||
if (!isGrowthBookEnabled()) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
// Reset the client completely to get fresh auth headers
|
||
// This is necessary because apiHostRequestHeaders can't be updated after creation
|
||
resetGrowthBook()
|
||
|
||
// resetGrowthBook cleared remoteEvalFeatureValues. If re-init below
|
||
// times out (hadFeatures=false) or short-circuits on !hasAuth (logout),
|
||
// the init-callback notify never fires — subscribers stay synced to the
|
||
// previous account's memoized state. Notify here so they re-read now
|
||
// (falls to disk cache). If re-init succeeds, they'll notify again with
|
||
// fresh values; if not, at least they're synced to the post-reset state.
|
||
refreshed.emit()
|
||
|
||
// Reinitialize with fresh auth headers and attributes
|
||
// Track this promise so security gate checks can wait for it.
|
||
// .catch before .finally: initializeGrowthBook can reject if its sync
|
||
// helpers throw (getGrowthBookClient, getAuthHeaders, resetGrowthBook —
|
||
// clientWrapper.initialized itself has its own .catch so never rejects),
|
||
// and .finally re-settles with the original rejection — the sync
|
||
// try/catch below cannot catch async rejections.
|
||
reinitializingPromise = initializeGrowthBook()
|
||
.catch(error => {
|
||
logError(toError(error))
|
||
return null
|
||
})
|
||
.finally(() => {
|
||
reinitializingPromise = null
|
||
})
|
||
} catch (error) {
|
||
if (process.env.NODE_ENV === 'development') {
|
||
throw error
|
||
}
|
||
logError(toError(error))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reset GrowthBook client state (primarily for testing)
|
||
*/
|
||
export function resetGrowthBook(): void {
|
||
stopPeriodicGrowthBookRefresh()
|
||
// Remove process handlers before destroying client to prevent accumulation
|
||
if (currentBeforeExitHandler) {
|
||
process.off('beforeExit', currentBeforeExitHandler)
|
||
currentBeforeExitHandler = null
|
||
}
|
||
if (currentExitHandler) {
|
||
process.off('exit', currentExitHandler)
|
||
currentExitHandler = null
|
||
}
|
||
client?.destroy()
|
||
client = null
|
||
clientCreatedWithAuth = false
|
||
reinitializingPromise = null
|
||
experimentDataByFeature.clear()
|
||
pendingExposures.clear()
|
||
loggedExposures.clear()
|
||
remoteEvalFeatureValues.clear()
|
||
getGrowthBookClient.cache?.clear?.()
|
||
initializeGrowthBook.cache?.clear?.()
|
||
envOverrides = null
|
||
envOverridesParsed = false
|
||
}
|
||
|
||
// Periodic refresh interval (matches Statsig's 6-hour interval)
|
||
const GROWTHBOOK_REFRESH_INTERVAL_MS =
|
||
process.env.USER_TYPE !== 'ant'
|
||
? 6 * 60 * 60 * 1000 // 6 hours
|
||
: 20 * 60 * 1000 // 20 min (for ants)
|
||
let refreshInterval: ReturnType<typeof setInterval> | null = null
|
||
let beforeExitListener: (() => void) | null = null
|
||
|
||
/**
|
||
* Light refresh - re-fetch features from server without recreating client.
|
||
* Use this for periodic refresh when auth headers haven't changed.
|
||
*
|
||
* Unlike refreshGrowthBookAfterAuthChange() which destroys and recreates the client,
|
||
* this preserves client state and just fetches fresh feature values.
|
||
*/
|
||
export async function refreshGrowthBookFeatures(): Promise<void> {
|
||
if (!isGrowthBookEnabled()) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const growthBookClient = await initializeGrowthBook()
|
||
if (!growthBookClient) {
|
||
return
|
||
}
|
||
|
||
await growthBookClient.refreshFeatures()
|
||
|
||
// Guard: if this client was replaced during the in-flight refresh
|
||
// (e.g. refreshGrowthBookAfterAuthChange ran), skip processing the
|
||
// stale payload. Mirrors the init-callback guard above.
|
||
if (growthBookClient !== client) {
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
logForDebugging(
|
||
'GrowthBook: Skipping refresh processing for replaced client',
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Rebuild remoteEvalFeatureValues from the refreshed payload so that
|
||
// _BLOCKS_ON_INIT callers (e.g. getMaxVersion for the auto-update kill
|
||
// switch) see fresh values, not the stale init-time snapshot.
|
||
const hadFeatures = await processRemoteEvalPayload(growthBookClient)
|
||
// Same re-check as init path: covers the setPayload yield inside
|
||
// processRemoteEvalPayload (the guard above only covers refreshFeatures).
|
||
if (growthBookClient !== client) return
|
||
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
logForDebugging('GrowthBook: Light refresh completed')
|
||
}
|
||
|
||
// Gate on hadFeatures: if the payload was empty/malformed,
|
||
// remoteEvalFeatureValues wasn't rebuilt — skip both the no-op disk
|
||
// write and the spurious subscriber churn (clearCommandMemoizationCaches
|
||
// + getCommands + 4× model re-renders).
|
||
if (hadFeatures) {
|
||
syncRemoteEvalToDisk()
|
||
refreshed.emit()
|
||
}
|
||
} catch (error) {
|
||
if (process.env.NODE_ENV === 'development') {
|
||
throw error
|
||
}
|
||
logError(toError(error))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set up periodic refresh of GrowthBook features.
|
||
* Uses light refresh (refreshGrowthBookFeatures) to re-fetch without recreating client.
|
||
*
|
||
* Call this after initialization for long-running sessions to ensure
|
||
* feature values stay fresh. Matches Statsig's 6-hour refresh interval.
|
||
*/
|
||
export function setupPeriodicGrowthBookRefresh(): void {
|
||
if (!isGrowthBookEnabled()) {
|
||
return
|
||
}
|
||
|
||
// Clear any existing interval to avoid duplicates
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval)
|
||
}
|
||
|
||
refreshInterval = setInterval(() => {
|
||
void refreshGrowthBookFeatures()
|
||
}, GROWTHBOOK_REFRESH_INTERVAL_MS)
|
||
// Allow process to exit naturally - this timer shouldn't keep the process alive
|
||
refreshInterval.unref?.()
|
||
|
||
// Register cleanup listener only once
|
||
if (!beforeExitListener) {
|
||
beforeExitListener = () => {
|
||
stopPeriodicGrowthBookRefresh()
|
||
}
|
||
process.once('beforeExit', beforeExitListener)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stop periodic refresh (for testing or cleanup)
|
||
*/
|
||
export function stopPeriodicGrowthBookRefresh(): void {
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval)
|
||
refreshInterval = null
|
||
}
|
||
if (beforeExitListener) {
|
||
process.removeListener('beforeExit', beforeExitListener)
|
||
beforeExitListener = null
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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 - blocks until GrowthBook is initialized.
|
||
* Prefer getFeatureValue_CACHED_MAY_BE_STALE for startup-critical paths.
|
||
*/
|
||
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 disk cache immediately. Pure read — see
|
||
* getFeatureValue_CACHED_MAY_BE_STALE.
|
||
* This is the preferred method for startup-critical paths and sync contexts.
|
||
*
|
||
* In GrowthBook, dynamic configs are just features with object values.
|
||
*/
|
||
export function getDynamicConfig_CACHED_MAY_BE_STALE<T>(
|
||
configName: string,
|
||
defaultValue: T,
|
||
): T {
|
||
return getFeatureValue_CACHED_MAY_BE_STALE(configName, defaultValue)
|
||
}
|