source dump of claude code
at main 1155 lines 40 kB view raw
1import { GrowthBook } from '@growthbook/growthbook' 2import { isEqual, memoize } from 'lodash-es' 3import { 4 getIsNonInteractiveSession, 5 getSessionTrustAccepted, 6} from '../../bootstrap/state.js' 7import { getGrowthBookClientKey } from '../../constants/keys.js' 8import { 9 checkHasTrustDialogAccepted, 10 getGlobalConfig, 11 saveGlobalConfig, 12} from '../../utils/config.js' 13import { logForDebugging } from '../../utils/debug.js' 14import { toError } from '../../utils/errors.js' 15import { getAuthHeaders } from '../../utils/http.js' 16import { logError } from '../../utils/log.js' 17import { createSignal } from '../../utils/signal.js' 18import { jsonStringify } from '../../utils/slowOperations.js' 19import { 20 type GitHubActionsMetadata, 21 getUserForGrowthBook, 22} from '../../utils/user.js' 23import { 24 is1PEventLoggingEnabled, 25 logGrowthBookExperimentTo1P, 26} from './firstPartyEventLogger.js' 27 28/** 29 * User attributes sent to GrowthBook for targeting. 30 * Uses UUID suffix (not Uuid) to align with GrowthBook conventions. 31 */ 32export type GrowthBookUserAttributes = { 33 id: string 34 sessionId: string 35 deviceID: string 36 platform: 'win32' | 'darwin' | 'linux' 37 apiBaseUrlHost?: string 38 organizationUUID?: string 39 accountUUID?: string 40 userType?: string 41 subscriptionType?: string 42 rateLimitTier?: string 43 firstTokenTime?: number 44 email?: string 45 appVersion?: string 46 github?: GitHubActionsMetadata 47} 48 49/** 50 * Malformed feature response from API that uses "value" instead of "defaultValue". 51 * This is a workaround until the API is fixed. 52 */ 53type MalformedFeatureDefinition = { 54 value?: unknown 55 defaultValue?: unknown 56 [key: string]: unknown 57} 58 59let client: GrowthBook | null = null 60 61// Named handler refs so resetGrowthBook can remove them to prevent accumulation 62let currentBeforeExitHandler: (() => void) | null = null 63let currentExitHandler: (() => void) | null = null 64 65// Track whether auth was available when the client was created 66// This allows us to detect when we need to recreate with fresh auth headers 67let clientCreatedWithAuth = false 68 69// Store experiment data from payload for logging exposures later 70type StoredExperimentData = { 71 experimentId: string 72 variationId: number 73 inExperiment?: boolean 74 hashAttribute?: string 75 hashValue?: string 76} 77const experimentDataByFeature = new Map<string, StoredExperimentData>() 78 79// Cache for remote eval feature values - workaround for SDK not respecting remoteEval response 80// The SDK's setForcedFeatures also doesn't work reliably with remoteEval 81const remoteEvalFeatureValues = new Map<string, unknown>() 82 83// Track features accessed before init that need exposure logging 84const pendingExposures = new Set<string>() 85 86// Track features that have already had their exposure logged this session (dedup) 87// This prevents firing duplicate exposure events when getFeatureValue_CACHED_MAY_BE_STALE 88// is called repeatedly in hot paths (e.g., isAutoMemoryEnabled in render loops) 89const loggedExposures = new Set<string>() 90 91// Track re-initialization promise for security gate checks 92// When GrowthBook is re-initializing (e.g., after auth change), security gate checks 93// should wait for init to complete to avoid returning stale values 94let reinitializingPromise: Promise<unknown> | null = null 95 96// Listeners notified when GrowthBook feature values refresh (initial init or 97// periodic refresh). Use for systems that bake feature values into long-lived 98// objects at construction time (e.g. firstPartyEventLogger reads 99// tengu_1p_event_batch_config once and builds a LoggerProvider with it) and 100// need to rebuild when config changes. Per-call readers like 101// getEventSamplingConfig / isSinkKilled don't need this — they're already 102// reactive. 103// 104// NOT cleared by resetGrowthBook — subscribers register once (typically in 105// init.ts) and must survive auth-change resets. 106type GrowthBookRefreshListener = () => void | Promise<void> 107const refreshed = createSignal() 108 109/** Call a listener with sync-throw and async-rejection both routed to logError. */ 110function callSafe(listener: GrowthBookRefreshListener): void { 111 try { 112 // Promise.resolve() normalizes sync returns and Promises so both 113 // sync throws (caught by outer try) and async rejections (caught 114 // by .catch) hit logError. Without the .catch, an async listener 115 // that rejects becomes an unhandled rejection — the try/catch 116 // only sees the Promise, not its eventual rejection. 117 void Promise.resolve(listener()).catch(e => { 118 logError(e) 119 }) 120 } catch (e) { 121 logError(e) 122 } 123} 124 125/** 126 * Register a callback to fire when GrowthBook feature values refresh. 127 * Returns an unsubscribe function. 128 * 129 * If init has already completed with features by the time this is called 130 * (remoteEvalFeatureValues is populated), the listener fires once on the 131 * next microtask. This catch-up handles the race where GB's network response 132 * lands before the REPL's useEffect commits — on external builds with fast 133 * networks and MCP-heavy configs, init can finish in ~100ms while REPL mount 134 * takes ~600ms (see #20951 external-build trace at 30.540 vs 31.046). 135 * 136 * Change detection is on the subscriber: the callback fires on every refresh; 137 * use isEqual against your last-seen config to decide whether to act. 138 */ 139export function onGrowthBookRefresh( 140 listener: GrowthBookRefreshListener, 141): () => void { 142 let subscribed = true 143 const unsubscribe = refreshed.subscribe(() => callSafe(listener)) 144 if (remoteEvalFeatureValues.size > 0) { 145 queueMicrotask(() => { 146 // Re-check: listener may have been removed, or resetGrowthBook may have 147 // cleared the Map, between registration and this microtask running. 148 if (subscribed && remoteEvalFeatureValues.size > 0) { 149 callSafe(listener) 150 } 151 }) 152 } 153 return () => { 154 subscribed = false 155 unsubscribe() 156 } 157} 158 159/** 160 * Parse env var overrides for GrowthBook features. 161 * Set CLAUDE_INTERNAL_FC_OVERRIDES to a JSON object mapping feature keys to values 162 * to bypass remote eval and disk cache. Useful for eval harnesses that need to 163 * test specific feature flag configurations. Only active when USER_TYPE is 'ant'. 164 * 165 * Example: CLAUDE_INTERNAL_FC_OVERRIDES='{"my_feature": true, "my_config": {"key": "val"}}' 166 */ 167let envOverrides: Record<string, unknown> | null = null 168let envOverridesParsed = false 169 170function getEnvOverrides(): Record<string, unknown> | null { 171 if (!envOverridesParsed) { 172 envOverridesParsed = true 173 if (process.env.USER_TYPE === 'ant') { 174 const raw = process.env.CLAUDE_INTERNAL_FC_OVERRIDES 175 if (raw) { 176 try { 177 envOverrides = JSON.parse(raw) as Record<string, unknown> 178 logForDebugging( 179 `GrowthBook: Using env var overrides for ${Object.keys(envOverrides!).length} features: ${Object.keys(envOverrides!).join(', ')}`, 180 ) 181 } catch { 182 logError( 183 new Error( 184 `GrowthBook: Failed to parse CLAUDE_INTERNAL_FC_OVERRIDES: ${raw}`, 185 ), 186 ) 187 } 188 } 189 } 190 } 191 return envOverrides 192} 193 194/** 195 * Check if a feature has an env-var override (CLAUDE_INTERNAL_FC_OVERRIDES). 196 * When true, _CACHED_MAY_BE_STALE will return the override without touching 197 * disk or network — callers can skip awaiting init for that feature. 198 */ 199export function hasGrowthBookEnvOverride(feature: string): boolean { 200 const overrides = getEnvOverrides() 201 return overrides !== null && feature in overrides 202} 203 204/** 205 * Local config overrides set via /config Gates tab (ant-only). Checked after 206 * env-var overrides — env wins so eval harnesses remain deterministic. Unlike 207 * getEnvOverrides this is not memoized: the user can change overrides at 208 * runtime, and getGlobalConfig() is already memory-cached (pointer-chase) 209 * until the next saveGlobalConfig() invalidates it. 210 */ 211function getConfigOverrides(): Record<string, unknown> | undefined { 212 if (process.env.USER_TYPE !== 'ant') return undefined 213 try { 214 return getGlobalConfig().growthBookOverrides 215 } catch { 216 // getGlobalConfig() throws before configReadingAllowed is set (early 217 // main.tsx startup path). Same degrade as the disk-cache fallback below. 218 return undefined 219 } 220} 221 222/** 223 * Enumerate all known GrowthBook features and their current resolved values 224 * (not including overrides). In-memory payload first, disk cache fallback — 225 * same priority as the getters. Used by the /config Gates tab. 226 */ 227export function getAllGrowthBookFeatures(): Record<string, unknown> { 228 if (remoteEvalFeatureValues.size > 0) { 229 return Object.fromEntries(remoteEvalFeatureValues) 230 } 231 return getGlobalConfig().cachedGrowthBookFeatures ?? {} 232} 233 234export function getGrowthBookConfigOverrides(): Record<string, unknown> { 235 return getConfigOverrides() ?? {} 236} 237 238/** 239 * Set or clear a single config override. Pass undefined to clear. 240 * Fires onGrowthBookRefresh listeners so systems that bake gate values into 241 * long-lived objects (useMainLoopModel, useSkillsChange, etc.) rebuild — 242 * otherwise overriding e.g. tengu_ant_model_override wouldn't actually 243 * change the model until the next periodic refresh. 244 */ 245export function setGrowthBookConfigOverride( 246 feature: string, 247 value: unknown, 248): void { 249 if (process.env.USER_TYPE !== 'ant') return 250 try { 251 saveGlobalConfig(c => { 252 const current = c.growthBookOverrides ?? {} 253 if (value === undefined) { 254 if (!(feature in current)) return c 255 const { [feature]: _, ...rest } = current 256 if (Object.keys(rest).length === 0) { 257 const { growthBookOverrides: __, ...configWithout } = c 258 return configWithout 259 } 260 return { ...c, growthBookOverrides: rest } 261 } 262 if (isEqual(current[feature], value)) return c 263 return { ...c, growthBookOverrides: { ...current, [feature]: value } } 264 }) 265 // Subscribers do their own change detection (see onGrowthBookRefresh docs), 266 // so firing on a no-op write is fine. 267 refreshed.emit() 268 } catch (e) { 269 logError(e) 270 } 271} 272 273export function clearGrowthBookConfigOverrides(): void { 274 if (process.env.USER_TYPE !== 'ant') return 275 try { 276 saveGlobalConfig(c => { 277 if ( 278 !c.growthBookOverrides || 279 Object.keys(c.growthBookOverrides).length === 0 280 ) { 281 return c 282 } 283 const { growthBookOverrides: _, ...rest } = c 284 return rest 285 }) 286 refreshed.emit() 287 } catch (e) { 288 logError(e) 289 } 290} 291 292/** 293 * Log experiment exposure for a feature if it has experiment data. 294 * Deduplicates within a session - each feature is logged at most once. 295 */ 296function logExposureForFeature(feature: string): void { 297 // Skip if already logged this session (dedup) 298 if (loggedExposures.has(feature)) { 299 return 300 } 301 302 const expData = experimentDataByFeature.get(feature) 303 if (expData) { 304 loggedExposures.add(feature) 305 logGrowthBookExperimentTo1P({ 306 experimentId: expData.experimentId, 307 variationId: expData.variationId, 308 userAttributes: getUserAttributes(), 309 experimentMetadata: { 310 feature_id: feature, 311 }, 312 }) 313 } 314} 315 316/** 317 * Process a remote eval payload from the GrowthBook server and populate 318 * local caches. Called after both initial client.init() and after 319 * client.refreshFeatures() so that _BLOCKS_ON_INIT callers see fresh values 320 * across the process lifetime, not just init-time snapshots. 321 * 322 * Without this running on refresh, remoteEvalFeatureValues freezes at its 323 * init-time snapshot and getDynamicConfig_BLOCKS_ON_INIT returns stale values 324 * for the entire process lifetime — which broke the tengu_max_version_config 325 * kill switch for long-running sessions. 326 */ 327async function processRemoteEvalPayload( 328 gbClient: GrowthBook, 329): Promise<boolean> { 330 // WORKAROUND: Transform remote eval response format 331 // The API returns { "value": ... } but SDK expects { "defaultValue": ... } 332 // TODO: Remove this once the API is fixed to return correct format 333 const payload = gbClient.getPayload() 334 // Empty object is truthy — without the length check, `{features: {}}` 335 // (transient server bug, truncated response) would pass, clear the maps 336 // below, return true, and syncRemoteEvalToDisk would wholesale-write `{}` 337 // to disk: total flag blackout for every process sharing ~/.claude.json. 338 if (!payload?.features || Object.keys(payload.features).length === 0) { 339 return false 340 } 341 342 // Clear before rebuild so features removed between refreshes don't 343 // leave stale ghost entries that short-circuit getFeatureValueInternal. 344 experimentDataByFeature.clear() 345 346 const transformedFeatures: Record<string, MalformedFeatureDefinition> = {} 347 for (const [key, feature] of Object.entries(payload.features)) { 348 const f = feature as MalformedFeatureDefinition 349 if ('value' in f && !('defaultValue' in f)) { 350 transformedFeatures[key] = { 351 ...f, 352 defaultValue: f.value, 353 } 354 } else { 355 transformedFeatures[key] = f 356 } 357 358 // Store experiment data for later logging when feature is accessed 359 if (f.source === 'experiment' && f.experimentResult) { 360 const expResult = f.experimentResult as { 361 variationId?: number 362 } 363 const exp = f.experiment as { key?: string } | undefined 364 if (exp?.key && expResult.variationId !== undefined) { 365 experimentDataByFeature.set(key, { 366 experimentId: exp.key, 367 variationId: expResult.variationId, 368 }) 369 } 370 } 371 } 372 // Re-set the payload with transformed features 373 await gbClient.setPayload({ 374 ...payload, 375 features: transformedFeatures, 376 }) 377 378 // WORKAROUND: Cache the evaluated values directly from remote eval response. 379 // The SDK's evalFeature() tries to re-evaluate rules locally, ignoring the 380 // pre-evaluated 'value' from remoteEval. setForcedFeatures also doesn't work 381 // reliably. So we cache values ourselves and use them in getFeatureValueInternal. 382 remoteEvalFeatureValues.clear() 383 for (const [key, feature] of Object.entries(transformedFeatures)) { 384 // Under remoteEval:true the server pre-evaluates. Whether the answer 385 // lands in `value` (current API) or `defaultValue` (post-TODO API shape), 386 // it's the authoritative value for this user. Guarding on both keeps 387 // syncRemoteEvalToDisk correct across a partial or full API migration. 388 const v = 'value' in feature ? feature.value : feature.defaultValue 389 if (v !== undefined) { 390 remoteEvalFeatureValues.set(key, v) 391 } 392 } 393 return true 394} 395 396/** 397 * Write the complete remoteEvalFeatureValues map to disk. Called exactly 398 * once per successful processRemoteEvalPayload — never from a failure path, 399 * so init-timeout poisoning is structurally impossible (the .catch() at init 400 * never reaches here). 401 * 402 * Wholesale replace (not merge): features deleted server-side are dropped 403 * from disk on the next successful payload. Ant builds ⊇ external, so 404 * switching builds is safe — the write is always a complete answer for this 405 * process's SDK key. 406 */ 407function syncRemoteEvalToDisk(): void { 408 const fresh = Object.fromEntries(remoteEvalFeatureValues) 409 const config = getGlobalConfig() 410 if (isEqual(config.cachedGrowthBookFeatures, fresh)) { 411 return 412 } 413 saveGlobalConfig(current => ({ 414 ...current, 415 cachedGrowthBookFeatures: fresh, 416 })) 417} 418 419/** 420 * Check if GrowthBook operations should be enabled 421 */ 422function isGrowthBookEnabled(): boolean { 423 // GrowthBook depends on 1P event logging. 424 return is1PEventLoggingEnabled() 425} 426 427/** 428 * Hostname of ANTHROPIC_BASE_URL when it points at a non-Anthropic proxy. 429 * 430 * Enterprise-proxy deployments (Epic, Marble, etc.) typically use 431 * apiKeyHelper auth, which means isAnthropicAuthEnabled() returns false and 432 * organizationUUID/accountUUID/email are all absent from GrowthBook 433 * attributes. Without this, there's no stable attribute to target them on 434 * — only per-device IDs. See src/utils/auth.ts isAnthropicAuthEnabled(). 435 * 436 * Returns undefined for unset/default (api.anthropic.com) so the attribute 437 * is absent for direct-API users. Hostname only — no path/query/creds. 438 */ 439export function getApiBaseUrlHost(): string | undefined { 440 const baseUrl = process.env.ANTHROPIC_BASE_URL 441 if (!baseUrl) return undefined 442 try { 443 const host = new URL(baseUrl).host 444 if (host === 'api.anthropic.com') return undefined 445 return host 446 } catch { 447 return undefined 448 } 449} 450 451/** 452 * Get user attributes for GrowthBook from CoreUserData 453 */ 454function getUserAttributes(): GrowthBookUserAttributes { 455 const user = getUserForGrowthBook() 456 457 // For ants, always try to include email from OAuth config even if ANTHROPIC_API_KEY is set. 458 // This ensures GrowthBook targeting by email works regardless of auth method. 459 let email = user.email 460 if (!email && process.env.USER_TYPE === 'ant') { 461 email = getGlobalConfig().oauthAccount?.emailAddress 462 } 463 464 const apiBaseUrlHost = getApiBaseUrlHost() 465 466 const attributes = { 467 id: user.deviceId, 468 sessionId: user.sessionId, 469 deviceID: user.deviceId, 470 platform: user.platform, 471 ...(apiBaseUrlHost && { apiBaseUrlHost }), 472 ...(user.organizationUuid && { organizationUUID: user.organizationUuid }), 473 ...(user.accountUuid && { accountUUID: user.accountUuid }), 474 ...(user.userType && { userType: user.userType }), 475 ...(user.subscriptionType && { subscriptionType: user.subscriptionType }), 476 ...(user.rateLimitTier && { rateLimitTier: user.rateLimitTier }), 477 ...(user.firstTokenTime && { firstTokenTime: user.firstTokenTime }), 478 ...(email && { email }), 479 ...(user.appVersion && { appVersion: user.appVersion }), 480 ...(user.githubActionsMetadata && { 481 githubActionsMetadata: user.githubActionsMetadata, 482 }), 483 } 484 return attributes 485} 486 487/** 488 * Get or create the GrowthBook client instance 489 */ 490const getGrowthBookClient = memoize( 491 (): { client: GrowthBook; initialized: Promise<void> } | null => { 492 if (!isGrowthBookEnabled()) { 493 return null 494 } 495 496 const attributes = getUserAttributes() 497 const clientKey = getGrowthBookClientKey() 498 if (process.env.USER_TYPE === 'ant') { 499 logForDebugging( 500 `GrowthBook: Creating client with clientKey=${clientKey}, attributes: ${jsonStringify(attributes)}`, 501 ) 502 } 503 const baseUrl = 504 process.env.USER_TYPE === 'ant' 505 ? process.env.CLAUDE_CODE_GB_BASE_URL || 'https://api.anthropic.com/' 506 : 'https://api.anthropic.com/' 507 508 // Skip auth if trust hasn't been established yet 509 // This prevents executing apiKeyHelper commands before the trust dialog 510 // Non-interactive sessions implicitly have workspace trust 511 // getSessionTrustAccepted() covers the case where the TrustDialog auto-resolved 512 // without persisting trust for the specific CWD (e.g., home directory) — 513 // showSetupScreens() sets this after the trust dialog flow completes. 514 const hasTrust = 515 checkHasTrustDialogAccepted() || 516 getSessionTrustAccepted() || 517 getIsNonInteractiveSession() 518 const authHeaders = hasTrust 519 ? getAuthHeaders() 520 : { headers: {}, error: 'trust not established' } 521 const hasAuth = !authHeaders.error 522 clientCreatedWithAuth = hasAuth 523 524 // Capture in local variable so the init callback operates on THIS client, 525 // not a later client if reinitialization happens before init completes 526 const thisClient = new GrowthBook({ 527 apiHost: baseUrl, 528 clientKey, 529 attributes, 530 remoteEval: true, 531 // Re-fetch when user ID or org changes (org change = login to different org) 532 cacheKeyAttributes: ['id', 'organizationUUID'], 533 // Add auth headers if available 534 ...(authHeaders.error 535 ? {} 536 : { apiHostRequestHeaders: authHeaders.headers }), 537 // Debug logging for Ants 538 ...(process.env.USER_TYPE === 'ant' 539 ? { 540 log: (msg: string, ctx: Record<string, unknown>) => { 541 logForDebugging(`GrowthBook: ${msg} ${jsonStringify(ctx)}`) 542 }, 543 } 544 : {}), 545 }) 546 client = thisClient 547 548 if (!hasAuth) { 549 // No auth available yet — skip HTTP init, rely on disk-cached values. 550 // initializeGrowthBook() will reset and re-create with auth when available. 551 return { client: thisClient, initialized: Promise.resolve() } 552 } 553 554 const initialized = thisClient 555 .init({ timeout: 5000 }) 556 .then(async result => { 557 // Guard: if this client was replaced by a newer one, skip processing 558 if (client !== thisClient) { 559 if (process.env.USER_TYPE === 'ant') { 560 logForDebugging( 561 'GrowthBook: Skipping init callback for replaced client', 562 ) 563 } 564 return 565 } 566 567 if (process.env.USER_TYPE === 'ant') { 568 logForDebugging( 569 `GrowthBook initialized successfully, source: ${result.source}, success: ${result.success}`, 570 ) 571 } 572 573 const hadFeatures = await processRemoteEvalPayload(thisClient) 574 // Re-check: processRemoteEvalPayload yields at `await setPayload`. 575 // Microtask-only today (no encryption, no sticky-bucket service), but 576 // the guard at the top of this callback runs before that await; 577 // this runs after. 578 if (client !== thisClient) return 579 580 if (hadFeatures) { 581 for (const feature of pendingExposures) { 582 logExposureForFeature(feature) 583 } 584 pendingExposures.clear() 585 syncRemoteEvalToDisk() 586 // Notify subscribers: remoteEvalFeatureValues is populated and 587 // disk is freshly synced. _CACHED_MAY_BE_STALE reads memory first 588 // (#22295), so subscribers see fresh values immediately. 589 refreshed.emit() 590 } 591 592 // Log what features were loaded 593 if (process.env.USER_TYPE === 'ant') { 594 const features = thisClient.getFeatures() 595 if (features) { 596 const featureKeys = Object.keys(features) 597 logForDebugging( 598 `GrowthBook loaded ${featureKeys.length} features: ${featureKeys.slice(0, 10).join(', ')}${featureKeys.length > 10 ? '...' : ''}`, 599 ) 600 } 601 } 602 }) 603 .catch(error => { 604 if (process.env.USER_TYPE === 'ant') { 605 logError(toError(error)) 606 } 607 }) 608 609 // Register cleanup handlers for graceful shutdown (named refs so resetGrowthBook can remove them) 610 currentBeforeExitHandler = () => client?.destroy() 611 currentExitHandler = () => client?.destroy() 612 process.on('beforeExit', currentBeforeExitHandler) 613 process.on('exit', currentExitHandler) 614 615 return { client: thisClient, initialized } 616 }, 617) 618 619/** 620 * Initialize GrowthBook client (blocks until ready) 621 */ 622export const initializeGrowthBook = memoize( 623 async (): Promise<GrowthBook | null> => { 624 let clientWrapper = getGrowthBookClient() 625 if (!clientWrapper) { 626 return null 627 } 628 629 // Check if auth has become available since the client was created 630 // If so, we need to recreate the client with fresh auth headers 631 // Only check if trust is established to avoid triggering apiKeyHelper before trust dialog 632 if (!clientCreatedWithAuth) { 633 const hasTrust = 634 checkHasTrustDialogAccepted() || 635 getSessionTrustAccepted() || 636 getIsNonInteractiveSession() 637 if (hasTrust) { 638 const currentAuth = getAuthHeaders() 639 if (!currentAuth.error) { 640 if (process.env.USER_TYPE === 'ant') { 641 logForDebugging( 642 'GrowthBook: Auth became available after client creation, reinitializing', 643 ) 644 } 645 // Use resetGrowthBook to properly destroy old client and stop periodic refresh 646 // This prevents double-init where old client's init promise continues running 647 resetGrowthBook() 648 clientWrapper = getGrowthBookClient() 649 if (!clientWrapper) { 650 return null 651 } 652 } 653 } 654 } 655 656 await clientWrapper.initialized 657 658 // Set up periodic refresh after successful initialization 659 // This is called here (not separately) so it's always re-established after any reinit 660 setupPeriodicGrowthBookRefresh() 661 662 return clientWrapper.client 663 }, 664) 665 666/** 667 * Get a feature value with a default fallback - blocks until initialized. 668 * @internal Used by both deprecated and cached functions. 669 */ 670async function getFeatureValueInternal<T>( 671 feature: string, 672 defaultValue: T, 673 logExposure: boolean, 674): Promise<T> { 675 // Check env var overrides first (for eval harnesses) 676 const overrides = getEnvOverrides() 677 if (overrides && feature in overrides) { 678 return overrides[feature] as T 679 } 680 const configOverrides = getConfigOverrides() 681 if (configOverrides && feature in configOverrides) { 682 return configOverrides[feature] as T 683 } 684 685 if (!isGrowthBookEnabled()) { 686 return defaultValue 687 } 688 689 const growthBookClient = await initializeGrowthBook() 690 if (!growthBookClient) { 691 return defaultValue 692 } 693 694 // Use cached remote eval values if available (workaround for SDK bug) 695 let result: T 696 if (remoteEvalFeatureValues.has(feature)) { 697 result = remoteEvalFeatureValues.get(feature) as T 698 } else { 699 result = growthBookClient.getFeatureValue(feature, defaultValue) as T 700 } 701 702 // Log experiment exposure using stored experiment data 703 if (logExposure) { 704 logExposureForFeature(feature) 705 } 706 707 if (process.env.USER_TYPE === 'ant') { 708 logForDebugging( 709 `GrowthBook: getFeatureValue("${feature}") = ${jsonStringify(result)}`, 710 ) 711 } 712 return result 713} 714 715/** 716 * @deprecated Use getFeatureValue_CACHED_MAY_BE_STALE instead, which is non-blocking. 717 * This function blocks on GrowthBook initialization which can slow down startup. 718 */ 719export async function getFeatureValue_DEPRECATED<T>( 720 feature: string, 721 defaultValue: T, 722): Promise<T> { 723 return getFeatureValueInternal(feature, defaultValue, true) 724} 725 726/** 727 * Get a feature value from disk cache immediately. Pure read — disk is 728 * populated by syncRemoteEvalToDisk on every successful payload (init + 729 * periodic refresh), not by this function. 730 * 731 * This is the preferred method for startup-critical paths and sync contexts. 732 * The value may be stale if the cache was written by a previous process. 733 */ 734export function getFeatureValue_CACHED_MAY_BE_STALE<T>( 735 feature: string, 736 defaultValue: T, 737): T { 738 // Check env var overrides first (for eval harnesses) 739 const overrides = getEnvOverrides() 740 if (overrides && feature in overrides) { 741 return overrides[feature] as T 742 } 743 const configOverrides = getConfigOverrides() 744 if (configOverrides && feature in configOverrides) { 745 return configOverrides[feature] as T 746 } 747 748 if (!isGrowthBookEnabled()) { 749 return defaultValue 750 } 751 752 // Log experiment exposure if data is available, otherwise defer until after init 753 if (experimentDataByFeature.has(feature)) { 754 logExposureForFeature(feature) 755 } else { 756 pendingExposures.add(feature) 757 } 758 759 // In-memory payload is authoritative once processRemoteEvalPayload has run. 760 // Disk is also fresh by then (syncRemoteEvalToDisk runs synchronously inside 761 // init), so this is correctness-equivalent to the disk read below — but it 762 // skips the config JSON parse and is what onGrowthBookRefresh subscribers 763 // depend on to read fresh values the instant they're notified. 764 if (remoteEvalFeatureValues.has(feature)) { 765 return remoteEvalFeatureValues.get(feature) as T 766 } 767 768 // Fall back to disk cache (survives across process restarts) 769 try { 770 const cached = getGlobalConfig().cachedGrowthBookFeatures?.[feature] 771 return cached !== undefined ? (cached as T) : defaultValue 772 } catch { 773 return defaultValue 774 } 775} 776 777/** 778 * @deprecated Disk cache is now synced on every successful payload load 779 * (init + 20min/6h periodic refresh). The per-feature TTL never fetched 780 * fresh data from the server — it only re-wrote in-memory state to disk, 781 * which is now redundant. Use getFeatureValue_CACHED_MAY_BE_STALE directly. 782 */ 783export function getFeatureValue_CACHED_WITH_REFRESH<T>( 784 feature: string, 785 defaultValue: T, 786 _refreshIntervalMs: number, 787): T { 788 return getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) 789} 790 791/** 792 * Check a Statsig feature gate value via GrowthBook, with fallback to Statsig cache. 793 * 794 * **MIGRATION ONLY**: This function is for migrating existing Statsig gates to GrowthBook. 795 * For new features, use `getFeatureValue_CACHED_MAY_BE_STALE()` instead. 796 * 797 * - Checks GrowthBook disk cache first 798 * - Falls back to Statsig's cachedStatsigGates during migration 799 * - The value may be stale if the cache hasn't been updated recently 800 * 801 * @deprecated Use getFeatureValue_CACHED_MAY_BE_STALE() for new code. This function 802 * exists only to support migration of existing Statsig gates. 803 */ 804export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 805 gate: string, 806): boolean { 807 // Check env var overrides first (for eval harnesses) 808 const overrides = getEnvOverrides() 809 if (overrides && gate in overrides) { 810 return Boolean(overrides[gate]) 811 } 812 const configOverrides = getConfigOverrides() 813 if (configOverrides && gate in configOverrides) { 814 return Boolean(configOverrides[gate]) 815 } 816 817 if (!isGrowthBookEnabled()) { 818 return false 819 } 820 821 // Log experiment exposure if data is available, otherwise defer until after init 822 if (experimentDataByFeature.has(gate)) { 823 logExposureForFeature(gate) 824 } else { 825 pendingExposures.add(gate) 826 } 827 828 // Return cached value immediately from disk 829 // First check GrowthBook cache, then fall back to Statsig cache for migration 830 const config = getGlobalConfig() 831 const gbCached = config.cachedGrowthBookFeatures?.[gate] 832 if (gbCached !== undefined) { 833 return Boolean(gbCached) 834 } 835 // Fallback to Statsig cache for migration period 836 return config.cachedStatsigGates?.[gate] ?? false 837} 838 839/** 840 * Check a security restriction gate, waiting for re-init if in progress. 841 * 842 * Use this for security-critical gates where we need fresh values after auth changes. 843 * 844 * Behavior: 845 * - If GrowthBook is re-initializing (e.g., after login), waits for it to complete 846 * - Otherwise, returns cached value immediately (Statsig cache first, then GrowthBook) 847 * 848 * Statsig cache is checked first as a safety measure for security-related checks: 849 * if the Statsig cache indicates the gate is enabled, we honor it. 850 */ 851export async function checkSecurityRestrictionGate( 852 gate: string, 853): Promise<boolean> { 854 // Check env var overrides first (for eval harnesses) 855 const overrides = getEnvOverrides() 856 if (overrides && gate in overrides) { 857 return Boolean(overrides[gate]) 858 } 859 const configOverrides = getConfigOverrides() 860 if (configOverrides && gate in configOverrides) { 861 return Boolean(configOverrides[gate]) 862 } 863 864 if (!isGrowthBookEnabled()) { 865 return false 866 } 867 868 // If re-initialization is in progress, wait for it to complete 869 // This ensures we get fresh values after auth changes 870 if (reinitializingPromise) { 871 await reinitializingPromise 872 } 873 874 // Check Statsig cache first - it may have correct value from previous logged-in session 875 const config = getGlobalConfig() 876 const statsigCached = config.cachedStatsigGates?.[gate] 877 if (statsigCached !== undefined) { 878 return Boolean(statsigCached) 879 } 880 881 // Then check GrowthBook cache 882 const gbCached = config.cachedGrowthBookFeatures?.[gate] 883 if (gbCached !== undefined) { 884 return Boolean(gbCached) 885 } 886 887 // No cache - return false (don't block on init for uncached gates) 888 return false 889} 890 891/** 892 * Check a boolean entitlement gate with fallback-to-blocking semantics. 893 * 894 * Fast path: if the disk cache already says `true`, return it immediately. 895 * Slow path: if disk says `false`/missing, await GrowthBook init and fetch the 896 * fresh server value (max ~5s). Disk is populated by syncRemoteEvalToDisk 897 * inside init, so by the time the slow path returns, disk already has the 898 * fresh value — no write needed here. 899 * 900 * Use for user-invoked features (e.g. /remote-control) that are gated on 901 * subscription/org, where a stale `false` would unfairly block access but a 902 * stale `true` is acceptable (the server is the real gatekeeper). 903 */ 904export async function checkGate_CACHED_OR_BLOCKING( 905 gate: string, 906): Promise<boolean> { 907 // Check env var overrides first (for eval harnesses) 908 const overrides = getEnvOverrides() 909 if (overrides && gate in overrides) { 910 return Boolean(overrides[gate]) 911 } 912 const configOverrides = getConfigOverrides() 913 if (configOverrides && gate in configOverrides) { 914 return Boolean(configOverrides[gate]) 915 } 916 917 if (!isGrowthBookEnabled()) { 918 return false 919 } 920 921 // Fast path: disk cache already says true — trust it 922 const cached = getGlobalConfig().cachedGrowthBookFeatures?.[gate] 923 if (cached === true) { 924 // Log experiment exposure if data is available, otherwise defer 925 if (experimentDataByFeature.has(gate)) { 926 logExposureForFeature(gate) 927 } else { 928 pendingExposures.add(gate) 929 } 930 return true 931 } 932 933 // Slow path: disk says false/missing — may be stale, fetch fresh 934 return getFeatureValueInternal(gate, false, true) 935} 936 937/** 938 * Refresh GrowthBook after auth changes (login/logout). 939 * 940 * NOTE: This must destroy and recreate the client because GrowthBook's 941 * apiHostRequestHeaders cannot be updated after client creation. 942 */ 943export function refreshGrowthBookAfterAuthChange(): void { 944 if (!isGrowthBookEnabled()) { 945 return 946 } 947 948 try { 949 // Reset the client completely to get fresh auth headers 950 // This is necessary because apiHostRequestHeaders can't be updated after creation 951 resetGrowthBook() 952 953 // resetGrowthBook cleared remoteEvalFeatureValues. If re-init below 954 // times out (hadFeatures=false) or short-circuits on !hasAuth (logout), 955 // the init-callback notify never fires — subscribers stay synced to the 956 // previous account's memoized state. Notify here so they re-read now 957 // (falls to disk cache). If re-init succeeds, they'll notify again with 958 // fresh values; if not, at least they're synced to the post-reset state. 959 refreshed.emit() 960 961 // Reinitialize with fresh auth headers and attributes 962 // Track this promise so security gate checks can wait for it. 963 // .catch before .finally: initializeGrowthBook can reject if its sync 964 // helpers throw (getGrowthBookClient, getAuthHeaders, resetGrowthBook — 965 // clientWrapper.initialized itself has its own .catch so never rejects), 966 // and .finally re-settles with the original rejection — the sync 967 // try/catch below cannot catch async rejections. 968 reinitializingPromise = initializeGrowthBook() 969 .catch(error => { 970 logError(toError(error)) 971 return null 972 }) 973 .finally(() => { 974 reinitializingPromise = null 975 }) 976 } catch (error) { 977 if (process.env.NODE_ENV === 'development') { 978 throw error 979 } 980 logError(toError(error)) 981 } 982} 983 984/** 985 * Reset GrowthBook client state (primarily for testing) 986 */ 987export function resetGrowthBook(): void { 988 stopPeriodicGrowthBookRefresh() 989 // Remove process handlers before destroying client to prevent accumulation 990 if (currentBeforeExitHandler) { 991 process.off('beforeExit', currentBeforeExitHandler) 992 currentBeforeExitHandler = null 993 } 994 if (currentExitHandler) { 995 process.off('exit', currentExitHandler) 996 currentExitHandler = null 997 } 998 client?.destroy() 999 client = null 1000 clientCreatedWithAuth = false 1001 reinitializingPromise = null 1002 experimentDataByFeature.clear() 1003 pendingExposures.clear() 1004 loggedExposures.clear() 1005 remoteEvalFeatureValues.clear() 1006 getGrowthBookClient.cache?.clear?.() 1007 initializeGrowthBook.cache?.clear?.() 1008 envOverrides = null 1009 envOverridesParsed = false 1010} 1011 1012// Periodic refresh interval (matches Statsig's 6-hour interval) 1013const GROWTHBOOK_REFRESH_INTERVAL_MS = 1014 process.env.USER_TYPE !== 'ant' 1015 ? 6 * 60 * 60 * 1000 // 6 hours 1016 : 20 * 60 * 1000 // 20 min (for ants) 1017let refreshInterval: ReturnType<typeof setInterval> | null = null 1018let beforeExitListener: (() => void) | null = null 1019 1020/** 1021 * Light refresh - re-fetch features from server without recreating client. 1022 * Use this for periodic refresh when auth headers haven't changed. 1023 * 1024 * Unlike refreshGrowthBookAfterAuthChange() which destroys and recreates the client, 1025 * this preserves client state and just fetches fresh feature values. 1026 */ 1027export async function refreshGrowthBookFeatures(): Promise<void> { 1028 if (!isGrowthBookEnabled()) { 1029 return 1030 } 1031 1032 try { 1033 const growthBookClient = await initializeGrowthBook() 1034 if (!growthBookClient) { 1035 return 1036 } 1037 1038 await growthBookClient.refreshFeatures() 1039 1040 // Guard: if this client was replaced during the in-flight refresh 1041 // (e.g. refreshGrowthBookAfterAuthChange ran), skip processing the 1042 // stale payload. Mirrors the init-callback guard above. 1043 if (growthBookClient !== client) { 1044 if (process.env.USER_TYPE === 'ant') { 1045 logForDebugging( 1046 'GrowthBook: Skipping refresh processing for replaced client', 1047 ) 1048 } 1049 return 1050 } 1051 1052 // Rebuild remoteEvalFeatureValues from the refreshed payload so that 1053 // _BLOCKS_ON_INIT callers (e.g. getMaxVersion for the auto-update kill 1054 // switch) see fresh values, not the stale init-time snapshot. 1055 const hadFeatures = await processRemoteEvalPayload(growthBookClient) 1056 // Same re-check as init path: covers the setPayload yield inside 1057 // processRemoteEvalPayload (the guard above only covers refreshFeatures). 1058 if (growthBookClient !== client) return 1059 1060 if (process.env.USER_TYPE === 'ant') { 1061 logForDebugging('GrowthBook: Light refresh completed') 1062 } 1063 1064 // Gate on hadFeatures: if the payload was empty/malformed, 1065 // remoteEvalFeatureValues wasn't rebuilt — skip both the no-op disk 1066 // write and the spurious subscriber churn (clearCommandMemoizationCaches 1067 // + getCommands + 4× model re-renders). 1068 if (hadFeatures) { 1069 syncRemoteEvalToDisk() 1070 refreshed.emit() 1071 } 1072 } catch (error) { 1073 if (process.env.NODE_ENV === 'development') { 1074 throw error 1075 } 1076 logError(toError(error)) 1077 } 1078} 1079 1080/** 1081 * Set up periodic refresh of GrowthBook features. 1082 * Uses light refresh (refreshGrowthBookFeatures) to re-fetch without recreating client. 1083 * 1084 * Call this after initialization for long-running sessions to ensure 1085 * feature values stay fresh. Matches Statsig's 6-hour refresh interval. 1086 */ 1087export function setupPeriodicGrowthBookRefresh(): void { 1088 if (!isGrowthBookEnabled()) { 1089 return 1090 } 1091 1092 // Clear any existing interval to avoid duplicates 1093 if (refreshInterval) { 1094 clearInterval(refreshInterval) 1095 } 1096 1097 refreshInterval = setInterval(() => { 1098 void refreshGrowthBookFeatures() 1099 }, GROWTHBOOK_REFRESH_INTERVAL_MS) 1100 // Allow process to exit naturally - this timer shouldn't keep the process alive 1101 refreshInterval.unref?.() 1102 1103 // Register cleanup listener only once 1104 if (!beforeExitListener) { 1105 beforeExitListener = () => { 1106 stopPeriodicGrowthBookRefresh() 1107 } 1108 process.once('beforeExit', beforeExitListener) 1109 } 1110} 1111 1112/** 1113 * Stop periodic refresh (for testing or cleanup) 1114 */ 1115export function stopPeriodicGrowthBookRefresh(): void { 1116 if (refreshInterval) { 1117 clearInterval(refreshInterval) 1118 refreshInterval = null 1119 } 1120 if (beforeExitListener) { 1121 process.removeListener('beforeExit', beforeExitListener) 1122 beforeExitListener = null 1123 } 1124} 1125 1126// ============================================================================ 1127// Dynamic Config Functions 1128// These are semantic wrappers around feature functions for Statsig API parity. 1129// In GrowthBook, dynamic configs are just features with object values. 1130// ============================================================================ 1131 1132/** 1133 * Get a dynamic config value - blocks until GrowthBook is initialized. 1134 * Prefer getFeatureValue_CACHED_MAY_BE_STALE for startup-critical paths. 1135 */ 1136export async function getDynamicConfig_BLOCKS_ON_INIT<T>( 1137 configName: string, 1138 defaultValue: T, 1139): Promise<T> { 1140 return getFeatureValue_DEPRECATED(configName, defaultValue) 1141} 1142 1143/** 1144 * Get a dynamic config value from disk cache immediately. Pure read — see 1145 * getFeatureValue_CACHED_MAY_BE_STALE. 1146 * This is the preferred method for startup-critical paths and sync contexts. 1147 * 1148 * In GrowthBook, dynamic configs are just features with object values. 1149 */ 1150export function getDynamicConfig_CACHED_MAY_BE_STALE<T>( 1151 configName: string, 1152 defaultValue: T, 1153): T { 1154 return getFeatureValue_CACHED_MAY_BE_STALE(configName, defaultValue) 1155}