forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 57
Expand file tree
/
Copy pathgrowthbook.ts
More file actions
1155 lines (1055 loc) · 39.6 KB
/
growthbook.ts
File metadata and controls
1155 lines (1055 loc) · 39.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
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