react-native-featureflags 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/dist/client.d.ts +69 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +707 -0
- package/dist/client.js.map +1 -0
- package/dist/hooks.d.ts +5 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +44 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/core.d.ts +91 -0
- package/dist/internal/core.d.ts.map +1 -0
- package/dist/internal/core.js +185 -0
- package/dist/internal/core.js.map +1 -0
- package/dist/singleton.d.ts +23 -0
- package/dist/singleton.d.ts.map +1 -0
- package/dist/singleton.js +52 -0
- package/dist/singleton.js.map +1 -0
- package/dist/storage.d.ts +16 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +21 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +14 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +33 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +32 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { FetchTransport, createAnonymousId, createDefaultLogger, evaluateBooleanFlag, evaluateFlagValue, getFlagPayload, indexFlags, loadCachedSnapshot, maskApiKey, persistCachedSnapshot, toAnonymousUser, validatePublicApiKey, withRetry, withTimeout, } from './internal/core.js';
|
|
2
|
+
import { FlagsStore } from './store.js';
|
|
3
|
+
import { memoryStorage } from './storage.js';
|
|
4
|
+
const ANON_ID_KEY = 'betsy:rn:anon-id';
|
|
5
|
+
const USER_CACHE_KEY = 'betsy:rn:user';
|
|
6
|
+
const SNAPSHOT_KEY = 'betsy:rn:snapshot';
|
|
7
|
+
const RELAY_BASE_URL = 'https://8nknkmxt07.execute-api.us-east-1.amazonaws.com/';
|
|
8
|
+
const DEFAULT_STREAM_URL = 'wss://hkst7yd32k.execute-api.us-east-1.amazonaws.com/prod';
|
|
9
|
+
const DEFAULT_CACHE_TTL_MS = 5 * 60_000;
|
|
10
|
+
const DEFAULT_POLL_MS = 60_000;
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 6_000;
|
|
12
|
+
const DEFAULT_TELEMETRY_FLUSH_MS = 10_000;
|
|
13
|
+
const DEFAULT_TELEMETRY_BATCH_SIZE = 50;
|
|
14
|
+
const DEFAULT_STREAM_RECONNECT_MS = 2_000;
|
|
15
|
+
const BLOCKED_ATTRIBUTE_KEYS = new Set([
|
|
16
|
+
'projectID',
|
|
17
|
+
'id',
|
|
18
|
+
'environmentID',
|
|
19
|
+
'currentUserID',
|
|
20
|
+
'deviceID',
|
|
21
|
+
'tenantGroup',
|
|
22
|
+
'isAnonymous',
|
|
23
|
+
'anonymousID',
|
|
24
|
+
'firstSeenAt',
|
|
25
|
+
'lastSeenAt',
|
|
26
|
+
'userAttributes',
|
|
27
|
+
]);
|
|
28
|
+
export class ReactNativeFlagsClient {
|
|
29
|
+
store = new FlagsStore();
|
|
30
|
+
sdkSource = "react-native";
|
|
31
|
+
pollTimer;
|
|
32
|
+
telemetryTimer;
|
|
33
|
+
config;
|
|
34
|
+
requestSequence = 0;
|
|
35
|
+
anonymousDeviceId;
|
|
36
|
+
attributesByUserId = new Map();
|
|
37
|
+
offlineFallbackFlags = {};
|
|
38
|
+
telemetryQueue = [];
|
|
39
|
+
exposureKeys = new Set();
|
|
40
|
+
assignmentKeys = new Set();
|
|
41
|
+
readEvaluationKeys = new Set();
|
|
42
|
+
streamSocket;
|
|
43
|
+
streamReconnectTimer;
|
|
44
|
+
latestConfigVersion;
|
|
45
|
+
configureSignature;
|
|
46
|
+
configureInFlight;
|
|
47
|
+
subscribe = (callback) => this.store.subscribe(callback);
|
|
48
|
+
getState = () => this.store.getState();
|
|
49
|
+
async configure(options) {
|
|
50
|
+
const signature = this.buildConfigureSignature(options);
|
|
51
|
+
if (this.configureInFlight && this.configureSignature === signature) {
|
|
52
|
+
return this.configureInFlight;
|
|
53
|
+
}
|
|
54
|
+
if (this.store.getState().configured && this.configureSignature === signature) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const configureTask = this.configureInternal(options, signature);
|
|
58
|
+
this.configureInFlight = configureTask;
|
|
59
|
+
return configureTask.finally(() => {
|
|
60
|
+
if (this.configureInFlight === configureTask) {
|
|
61
|
+
this.configureInFlight = undefined;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async configureInternal(options, signature) {
|
|
66
|
+
validatePublicApiKey(options.apiKey);
|
|
67
|
+
this.stopStreaming();
|
|
68
|
+
this.stopPolling();
|
|
69
|
+
this.configureSignature = signature;
|
|
70
|
+
this.config = options;
|
|
71
|
+
const logger = options.logger ?? createDefaultLogger(false);
|
|
72
|
+
logger.info('Configuring Betsy RN SDK', { apiKey: maskApiKey(options.apiKey) });
|
|
73
|
+
const storage = options.storage ?? memoryStorage;
|
|
74
|
+
this.anonymousDeviceId = (await storage.getItem(ANON_ID_KEY)) ?? undefined;
|
|
75
|
+
const cachedUser = await this.loadCachedUser(storage);
|
|
76
|
+
const user = this.normalizeUser(options.user ?? cachedUser ?? (await this.resolveAnonymousUser(storage)));
|
|
77
|
+
await this.persistCachedUser(storage, user);
|
|
78
|
+
await this.ensureAnonymousDeviceId(storage, user);
|
|
79
|
+
const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
80
|
+
const cachedSnapshot = await loadCachedSnapshot(storage, SNAPSHOT_KEY, cacheTtlMs);
|
|
81
|
+
this.offlineFallbackFlags = indexFlags(cachedSnapshot?.offlineFallbackFlags ?? []);
|
|
82
|
+
const initialState = {
|
|
83
|
+
configured: true,
|
|
84
|
+
ready: Boolean(cachedSnapshot),
|
|
85
|
+
apiKey: options.apiKey,
|
|
86
|
+
user,
|
|
87
|
+
flags: indexFlags(cachedSnapshot?.flags ?? []),
|
|
88
|
+
};
|
|
89
|
+
if (cachedSnapshot?.fetchedAt !== undefined) {
|
|
90
|
+
initialState.lastFetchAt = cachedSnapshot.fetchedAt;
|
|
91
|
+
}
|
|
92
|
+
this.store.setState(initialState);
|
|
93
|
+
this.latestConfigVersion = undefined;
|
|
94
|
+
this.exposureKeys.clear();
|
|
95
|
+
this.assignmentKeys.clear();
|
|
96
|
+
this.readEvaluationKeys.clear();
|
|
97
|
+
this.clearError();
|
|
98
|
+
await this.refresh();
|
|
99
|
+
this.startStreamOrPolling();
|
|
100
|
+
this.startTelemetryFlush();
|
|
101
|
+
}
|
|
102
|
+
async login(user) {
|
|
103
|
+
this.requireConfigured();
|
|
104
|
+
const nextUser = this.mergeUser(user);
|
|
105
|
+
this.store.patch({ user: nextUser });
|
|
106
|
+
this.exposureKeys.clear();
|
|
107
|
+
this.assignmentKeys.clear();
|
|
108
|
+
this.readEvaluationKeys.clear();
|
|
109
|
+
await this.persistCachedUser(this.getStorage(), nextUser);
|
|
110
|
+
await this.refresh();
|
|
111
|
+
}
|
|
112
|
+
async setAttributes(attributes) {
|
|
113
|
+
this.requireConfigured();
|
|
114
|
+
const currentUser = this.getUser();
|
|
115
|
+
const nextUser = this.mergeUser({ id: currentUser.id, attributes });
|
|
116
|
+
this.store.patch({ user: nextUser });
|
|
117
|
+
this.exposureKeys.clear();
|
|
118
|
+
this.assignmentKeys.clear();
|
|
119
|
+
this.readEvaluationKeys.clear();
|
|
120
|
+
await this.persistCachedUser(this.getStorage(), nextUser);
|
|
121
|
+
await this.refresh();
|
|
122
|
+
}
|
|
123
|
+
async identify(user) {
|
|
124
|
+
await this.login(user);
|
|
125
|
+
}
|
|
126
|
+
async logout() {
|
|
127
|
+
this.requireConfigured();
|
|
128
|
+
const storage = this.getStorage();
|
|
129
|
+
await this.ensureAnonymousDeviceId(storage, this.getUser());
|
|
130
|
+
const user = this.normalizeUser(toAnonymousUser(createAnonymousId()));
|
|
131
|
+
this.store.patch({ user });
|
|
132
|
+
this.exposureKeys.clear();
|
|
133
|
+
this.assignmentKeys.clear();
|
|
134
|
+
this.readEvaluationKeys.clear();
|
|
135
|
+
await this.persistCachedUser(storage, user);
|
|
136
|
+
await this.refresh();
|
|
137
|
+
}
|
|
138
|
+
async refresh() {
|
|
139
|
+
this.requireConfigured();
|
|
140
|
+
const config = this.config;
|
|
141
|
+
const logger = config.logger ?? createDefaultLogger(false);
|
|
142
|
+
const storage = this.getStorage();
|
|
143
|
+
const sequence = ++this.requestSequence;
|
|
144
|
+
try {
|
|
145
|
+
const snapshot = await withRetry(async () => {
|
|
146
|
+
return withTimeout(config.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS, async (signal) => {
|
|
147
|
+
const activeUser = this.getUser();
|
|
148
|
+
const query = {
|
|
149
|
+
userId: activeUser.id,
|
|
150
|
+
};
|
|
151
|
+
const userAttributes = activeUser.attributes ?? this.attributesByUserId.get(activeUser.id);
|
|
152
|
+
if (userAttributes) {
|
|
153
|
+
query.userAttributes = JSON.stringify(userAttributes);
|
|
154
|
+
}
|
|
155
|
+
if (this.anonymousDeviceId) {
|
|
156
|
+
query.anonymousId = this.anonymousDeviceId;
|
|
157
|
+
}
|
|
158
|
+
const response = await this.getTransport().get('/v1/flags', {
|
|
159
|
+
apiKey: config.apiKey,
|
|
160
|
+
signal,
|
|
161
|
+
query,
|
|
162
|
+
});
|
|
163
|
+
const typed = response;
|
|
164
|
+
const meta = response.meta;
|
|
165
|
+
const offlineFallbackFlags = this.extractOfflineFallbackFlags(typed.flags ?? []);
|
|
166
|
+
const configVersion = typeof meta?.configVersion === 'string' ? meta.configVersion : undefined;
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
return {
|
|
169
|
+
flags: typed.flags ?? [],
|
|
170
|
+
fetchedAt: now,
|
|
171
|
+
offlineFallbackFlags: Object.values(offlineFallbackFlags),
|
|
172
|
+
configVersion,
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
if (sequence !== this.requestSequence)
|
|
177
|
+
return;
|
|
178
|
+
const nextFlags = indexFlags(snapshot.flags);
|
|
179
|
+
this.offlineFallbackFlags = indexFlags(snapshot.offlineFallbackFlags ?? []);
|
|
180
|
+
this.store.patch({
|
|
181
|
+
flags: nextFlags,
|
|
182
|
+
lastFetchAt: snapshot.fetchedAt,
|
|
183
|
+
ready: true,
|
|
184
|
+
});
|
|
185
|
+
this.latestConfigVersion = snapshot.configVersion;
|
|
186
|
+
this.enqueueAssignmentEvents(snapshot.flags);
|
|
187
|
+
this.clearError();
|
|
188
|
+
await persistCachedSnapshot(storage, SNAPSHOT_KEY, snapshot);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
logger.error('Betsy refresh failed', {
|
|
192
|
+
error: error instanceof Error ? error.message : String(error),
|
|
193
|
+
});
|
|
194
|
+
this.store.patch({
|
|
195
|
+
error: error instanceof Error ? error.message : 'Unknown refresh error',
|
|
196
|
+
});
|
|
197
|
+
if (Object.keys(this.offlineFallbackFlags).length > 0) {
|
|
198
|
+
this.store.patch({
|
|
199
|
+
flags: this.offlineFallbackFlags,
|
|
200
|
+
ready: true,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else if (Object.keys(this.store.getState().flags).length > 0) {
|
|
204
|
+
this.store.patch({ ready: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async track(eventName, properties, options) {
|
|
209
|
+
this.requireConfigured();
|
|
210
|
+
if (!eventName || !eventName.trim())
|
|
211
|
+
return;
|
|
212
|
+
const user = this.getUser();
|
|
213
|
+
this.enqueueTelemetryEvent({
|
|
214
|
+
name: eventName.trim(),
|
|
215
|
+
timestamp: new Date().toISOString(),
|
|
216
|
+
userId: user.id,
|
|
217
|
+
anonymousId: this.anonymousDeviceId,
|
|
218
|
+
userAttributes: user.attributes ?? this.attributesByUserId.get(user.id),
|
|
219
|
+
properties: properties ?? {},
|
|
220
|
+
}, 'track');
|
|
221
|
+
const maxBatch = this.config?.telemetryBatchSize ?? DEFAULT_TELEMETRY_BATCH_SIZE;
|
|
222
|
+
if (options?.immediate || this.telemetryQueue.length >= maxBatch) {
|
|
223
|
+
await this.flushTelemetry();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async expose(flagKey, options) {
|
|
227
|
+
this.requireConfigured();
|
|
228
|
+
const flag = this.store.getState().flags[flagKey];
|
|
229
|
+
if (!flag)
|
|
230
|
+
return;
|
|
231
|
+
await this.enqueueExposure(flag, options);
|
|
232
|
+
}
|
|
233
|
+
notifyFlagRead(flagKey) {
|
|
234
|
+
const state = this.store.getState();
|
|
235
|
+
if (!state.ready)
|
|
236
|
+
return;
|
|
237
|
+
const flag = state.flags[flagKey];
|
|
238
|
+
if (!flag)
|
|
239
|
+
return;
|
|
240
|
+
this.enqueueReadEvaluationEvents(flag);
|
|
241
|
+
void this.enqueueExposure(flag);
|
|
242
|
+
}
|
|
243
|
+
getFlag(key, defaultValue = false) {
|
|
244
|
+
return evaluateBooleanFlag(this.store.getState().flags, key, defaultValue);
|
|
245
|
+
}
|
|
246
|
+
getFeatureFlagValue(key, defaultValue) {
|
|
247
|
+
return evaluateFlagValue(this.store.getState().flags, key, defaultValue);
|
|
248
|
+
}
|
|
249
|
+
getFeatureFlagPayload(key) {
|
|
250
|
+
return getFlagPayload(this.store.getState().flags, key);
|
|
251
|
+
}
|
|
252
|
+
getAllFlags() {
|
|
253
|
+
const output = {};
|
|
254
|
+
for (const [key, flag] of Object.entries(this.store.getState().flags)) {
|
|
255
|
+
this.enqueueReadEvaluationEvents(flag);
|
|
256
|
+
void this.enqueueExposure(flag);
|
|
257
|
+
output[key] = flag.value;
|
|
258
|
+
}
|
|
259
|
+
return output;
|
|
260
|
+
}
|
|
261
|
+
isReady() {
|
|
262
|
+
return this.store.getState().ready;
|
|
263
|
+
}
|
|
264
|
+
destroy() {
|
|
265
|
+
this.stopPolling();
|
|
266
|
+
if (this.telemetryTimer)
|
|
267
|
+
clearTimeout(this.telemetryTimer);
|
|
268
|
+
this.stopStreaming();
|
|
269
|
+
}
|
|
270
|
+
getStorage() {
|
|
271
|
+
return this.config?.storage ?? memoryStorage;
|
|
272
|
+
}
|
|
273
|
+
getUser() {
|
|
274
|
+
const user = this.store.getState().user;
|
|
275
|
+
if (!user) {
|
|
276
|
+
throw new Error('SDK has no active user identity');
|
|
277
|
+
}
|
|
278
|
+
return user;
|
|
279
|
+
}
|
|
280
|
+
getTransport() {
|
|
281
|
+
const config = this.config;
|
|
282
|
+
return new FetchTransport(RELAY_BASE_URL, config.fetchFn);
|
|
283
|
+
}
|
|
284
|
+
enqueueTelemetryEvent(event, reason) {
|
|
285
|
+
this.telemetryQueue.push(event);
|
|
286
|
+
const logger = this.config?.logger ?? createDefaultLogger(false);
|
|
287
|
+
const eventName = typeof event.name === 'string' ? event.name : 'unknown';
|
|
288
|
+
const properties = event.properties && typeof event.properties === 'object' && !Array.isArray(event.properties)
|
|
289
|
+
? event.properties
|
|
290
|
+
: undefined;
|
|
291
|
+
logger.debug('Betsy telemetry queued', {
|
|
292
|
+
reason,
|
|
293
|
+
eventName,
|
|
294
|
+
queueSize: this.telemetryQueue.length,
|
|
295
|
+
userId: typeof event.userId === 'string' ? event.userId : undefined,
|
|
296
|
+
flagKey: typeof properties?.flagKey === 'string' ? properties.flagKey : undefined,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
buildConfigureSignature(options) {
|
|
300
|
+
const attrs = options.user?.attributes;
|
|
301
|
+
const normalizedAttrs = attrs && typeof attrs === 'object'
|
|
302
|
+
? Object.keys(attrs)
|
|
303
|
+
.sort()
|
|
304
|
+
.map((key) => [key, attrs[key]])
|
|
305
|
+
: [];
|
|
306
|
+
return JSON.stringify({
|
|
307
|
+
apiKey: options.apiKey,
|
|
308
|
+
userId: options.user?.id ?? '',
|
|
309
|
+
userAttributes: normalizedAttrs,
|
|
310
|
+
cacheTtlMs: options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS,
|
|
311
|
+
pollingIntervalMs: options.pollingIntervalMs ?? DEFAULT_POLL_MS,
|
|
312
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
313
|
+
streamEnabled: options.streamEnabled ?? true,
|
|
314
|
+
streamUrl: options.streamUrl ?? '',
|
|
315
|
+
telemetryEnabled: options.telemetryEnabled ?? true,
|
|
316
|
+
telemetryFlushIntervalMs: options.telemetryFlushIntervalMs ?? DEFAULT_TELEMETRY_FLUSH_MS,
|
|
317
|
+
telemetryBatchSize: options.telemetryBatchSize ?? DEFAULT_TELEMETRY_BATCH_SIZE,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
requireConfigured() {
|
|
321
|
+
if (!this.config || !this.store.getState().configured) {
|
|
322
|
+
throw new Error('Flags.configure must be called before this operation');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async resolveAnonymousUser(storage) {
|
|
326
|
+
const existing = await storage.getItem(ANON_ID_KEY);
|
|
327
|
+
if (existing) {
|
|
328
|
+
this.anonymousDeviceId = existing;
|
|
329
|
+
return toAnonymousUser(existing);
|
|
330
|
+
}
|
|
331
|
+
const next = createAnonymousId();
|
|
332
|
+
await storage.setItem(ANON_ID_KEY, next);
|
|
333
|
+
this.anonymousDeviceId = next;
|
|
334
|
+
return toAnonymousUser(next);
|
|
335
|
+
}
|
|
336
|
+
async ensureAnonymousDeviceId(storage, user) {
|
|
337
|
+
if (this.anonymousDeviceId)
|
|
338
|
+
return;
|
|
339
|
+
const persisted = await storage.getItem(ANON_ID_KEY);
|
|
340
|
+
if (persisted) {
|
|
341
|
+
this.anonymousDeviceId = persisted;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const deviceId = user.id.startsWith('anon_') ? user.id : createAnonymousId();
|
|
345
|
+
this.anonymousDeviceId = deviceId;
|
|
346
|
+
await storage.setItem(ANON_ID_KEY, deviceId);
|
|
347
|
+
}
|
|
348
|
+
async loadCachedUser(storage) {
|
|
349
|
+
const raw = await storage.getItem(USER_CACHE_KEY);
|
|
350
|
+
if (!raw)
|
|
351
|
+
return undefined;
|
|
352
|
+
try {
|
|
353
|
+
const parsed = this.normalizeUser(JSON.parse(raw));
|
|
354
|
+
if (!parsed?.id || typeof parsed.id !== 'string')
|
|
355
|
+
return undefined;
|
|
356
|
+
return parsed;
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async persistCachedUser(storage, user) {
|
|
363
|
+
await storage.setItem(USER_CACHE_KEY, JSON.stringify(user));
|
|
364
|
+
}
|
|
365
|
+
normalizeUser(user) {
|
|
366
|
+
const normalized = { id: String(user.id) };
|
|
367
|
+
const attrs = this.normalizeAttributes(user.attributes);
|
|
368
|
+
if (attrs) {
|
|
369
|
+
normalized.attributes = attrs;
|
|
370
|
+
this.attributesByUserId.set(normalized.id, attrs);
|
|
371
|
+
}
|
|
372
|
+
return normalized;
|
|
373
|
+
}
|
|
374
|
+
normalizeAttributes(attributes) {
|
|
375
|
+
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
const blocked = Object.keys(attributes).filter((key) => BLOCKED_ATTRIBUTE_KEYS.has(key));
|
|
379
|
+
if (blocked.length > 0) {
|
|
380
|
+
throw new Error(`Blocked attribute key(s): ${blocked.join(', ')}. Remove reserved keys from user.attributes.`);
|
|
381
|
+
}
|
|
382
|
+
return { ...attributes };
|
|
383
|
+
}
|
|
384
|
+
mergeUser(user) {
|
|
385
|
+
const userId = String(user.id);
|
|
386
|
+
const incoming = this.normalizeAttributes(user.attributes);
|
|
387
|
+
const existing = this.attributesByUserId.get(userId);
|
|
388
|
+
const merged = { ...(existing ?? {}), ...(incoming ?? {}) };
|
|
389
|
+
const normalized = { id: userId };
|
|
390
|
+
if (Object.keys(merged).length > 0) {
|
|
391
|
+
normalized.attributes = merged;
|
|
392
|
+
this.attributesByUserId.set(userId, merged);
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
this.attributesByUserId.delete(userId);
|
|
396
|
+
}
|
|
397
|
+
return normalized;
|
|
398
|
+
}
|
|
399
|
+
startPolling() {
|
|
400
|
+
if (this.pollTimer)
|
|
401
|
+
clearTimeout(this.pollTimer);
|
|
402
|
+
const interval = this.config?.pollingIntervalMs ?? DEFAULT_POLL_MS;
|
|
403
|
+
this.pollTimer = setTimeout(() => {
|
|
404
|
+
void this.refresh().finally(() => this.startPolling());
|
|
405
|
+
}, interval);
|
|
406
|
+
const timer = this.pollTimer;
|
|
407
|
+
timer.unref?.();
|
|
408
|
+
}
|
|
409
|
+
stopPolling() {
|
|
410
|
+
if (!this.pollTimer)
|
|
411
|
+
return;
|
|
412
|
+
clearTimeout(this.pollTimer);
|
|
413
|
+
this.pollTimer = undefined;
|
|
414
|
+
}
|
|
415
|
+
startStreamOrPolling() {
|
|
416
|
+
const streamEnabled = this.config?.streamEnabled ?? true;
|
|
417
|
+
const streamUrl = this.resolveStreamUrl();
|
|
418
|
+
if (!streamEnabled || !streamUrl) {
|
|
419
|
+
this.startPolling();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
this.startStreaming(streamUrl);
|
|
423
|
+
}
|
|
424
|
+
startStreaming(streamBaseUrl) {
|
|
425
|
+
this.stopStreaming();
|
|
426
|
+
const config = this.config;
|
|
427
|
+
const logger = config.logger ?? createDefaultLogger(false);
|
|
428
|
+
const url = new URL(streamBaseUrl);
|
|
429
|
+
url.searchParams.set('apiKey', config.apiKey);
|
|
430
|
+
let socket;
|
|
431
|
+
try {
|
|
432
|
+
socket = new WebSocket(url.toString());
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
logger.warn('Betsy stream open failed, using polling fallback');
|
|
436
|
+
this.startPolling();
|
|
437
|
+
this.scheduleStreamReconnect();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
this.streamSocket = socket;
|
|
441
|
+
this.startPolling();
|
|
442
|
+
socket.onopen = () => {
|
|
443
|
+
logger.info('Betsy stream connected');
|
|
444
|
+
this.stopPolling();
|
|
445
|
+
};
|
|
446
|
+
socket.onmessage = (event) => {
|
|
447
|
+
this.handleStreamMessage(event.data);
|
|
448
|
+
};
|
|
449
|
+
socket.onerror = () => {
|
|
450
|
+
logger.warn('Betsy stream error, polling fallback active');
|
|
451
|
+
this.startPolling();
|
|
452
|
+
};
|
|
453
|
+
socket.onclose = (event) => {
|
|
454
|
+
logger.warn('Betsy stream closed, reconnect scheduled', {
|
|
455
|
+
code: event.code,
|
|
456
|
+
reason: event.reason,
|
|
457
|
+
});
|
|
458
|
+
this.startPolling();
|
|
459
|
+
this.scheduleStreamReconnect();
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
stopStreaming() {
|
|
463
|
+
if (this.streamReconnectTimer) {
|
|
464
|
+
clearTimeout(this.streamReconnectTimer);
|
|
465
|
+
this.streamReconnectTimer = undefined;
|
|
466
|
+
}
|
|
467
|
+
if (!this.streamSocket)
|
|
468
|
+
return;
|
|
469
|
+
const socket = this.streamSocket;
|
|
470
|
+
this.streamSocket = undefined;
|
|
471
|
+
socket.onopen = null;
|
|
472
|
+
socket.onmessage = null;
|
|
473
|
+
socket.onerror = null;
|
|
474
|
+
socket.onclose = null;
|
|
475
|
+
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
|
476
|
+
socket.close();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
scheduleStreamReconnect() {
|
|
480
|
+
if (this.streamReconnectTimer)
|
|
481
|
+
return;
|
|
482
|
+
const delayMs = Math.max(DEFAULT_STREAM_RECONNECT_MS, (this.config?.pollingIntervalMs ?? DEFAULT_POLL_MS) / 2);
|
|
483
|
+
this.streamReconnectTimer = setTimeout(() => {
|
|
484
|
+
this.streamReconnectTimer = undefined;
|
|
485
|
+
if (!this.config)
|
|
486
|
+
return;
|
|
487
|
+
const logger = this.config.logger ?? createDefaultLogger(false);
|
|
488
|
+
logger.debug('Betsy stream reconnecting');
|
|
489
|
+
this.startStreamOrPolling();
|
|
490
|
+
}, delayMs);
|
|
491
|
+
const timer = this.streamReconnectTimer;
|
|
492
|
+
timer.unref?.();
|
|
493
|
+
}
|
|
494
|
+
handleStreamMessage(raw) {
|
|
495
|
+
if (typeof raw !== 'string')
|
|
496
|
+
return;
|
|
497
|
+
let payload;
|
|
498
|
+
try {
|
|
499
|
+
payload = JSON.parse(raw);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const messageType = typeof payload.type === 'string' ? payload.type : '';
|
|
505
|
+
if (messageType !== 'config_published')
|
|
506
|
+
return;
|
|
507
|
+
const incomingVersion = typeof payload.version === 'string' ? payload.version : '';
|
|
508
|
+
if (incomingVersion && this.latestConfigVersion && incomingVersion === this.latestConfigVersion) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
void this.refresh();
|
|
512
|
+
}
|
|
513
|
+
resolveStreamUrl() {
|
|
514
|
+
const configured = this.config?.streamUrl?.trim();
|
|
515
|
+
if (configured)
|
|
516
|
+
return configured;
|
|
517
|
+
return DEFAULT_STREAM_URL;
|
|
518
|
+
}
|
|
519
|
+
startTelemetryFlush() {
|
|
520
|
+
if (this.telemetryTimer)
|
|
521
|
+
clearTimeout(this.telemetryTimer);
|
|
522
|
+
const interval = this.config?.telemetryFlushIntervalMs ?? DEFAULT_TELEMETRY_FLUSH_MS;
|
|
523
|
+
this.telemetryTimer = setTimeout(() => {
|
|
524
|
+
void this.flushTelemetry().finally(() => this.startTelemetryFlush());
|
|
525
|
+
}, interval);
|
|
526
|
+
const timer = this.telemetryTimer;
|
|
527
|
+
timer.unref?.();
|
|
528
|
+
}
|
|
529
|
+
async flushTelemetry() {
|
|
530
|
+
this.requireConfigured();
|
|
531
|
+
if ((this.config?.telemetryEnabled ?? true) === false)
|
|
532
|
+
return;
|
|
533
|
+
if (!this.telemetryQueue.length)
|
|
534
|
+
return;
|
|
535
|
+
const logger = this.config?.logger ?? createDefaultLogger(false);
|
|
536
|
+
const batchSize = this.config?.telemetryBatchSize ?? DEFAULT_TELEMETRY_BATCH_SIZE;
|
|
537
|
+
const events = this.telemetryQueue.slice(0, batchSize);
|
|
538
|
+
try {
|
|
539
|
+
logger.debug('Betsy telemetry flush started', {
|
|
540
|
+
count: events.length,
|
|
541
|
+
eventNames: events.map((event) => String(event.name ?? 'unknown')),
|
|
542
|
+
});
|
|
543
|
+
await this.getTransport().post('/v1/events/batch', {
|
|
544
|
+
apiKey: this.config.apiKey,
|
|
545
|
+
body: {
|
|
546
|
+
events,
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
this.telemetryQueue.splice(0, events.length);
|
|
550
|
+
logger.debug('Betsy telemetry flush succeeded', {
|
|
551
|
+
flushedCount: events.length,
|
|
552
|
+
queueSize: this.telemetryQueue.length,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
logger.warn('Betsy telemetry flush failed; keeping events queued', {
|
|
557
|
+
attemptedCount: events.length,
|
|
558
|
+
queueSize: this.telemetryQueue.length,
|
|
559
|
+
});
|
|
560
|
+
// Keep events queued for next flush attempt.
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
enqueueReadEvaluationEvents(flag) {
|
|
564
|
+
if ((this.config?.telemetryEnabled ?? true) === false)
|
|
565
|
+
return;
|
|
566
|
+
const now = new Date().toISOString();
|
|
567
|
+
const user = this.store.getState().user;
|
|
568
|
+
const payload = flag.payload ?? {};
|
|
569
|
+
const variationId = typeof payload === 'object' && typeof payload.variationId === 'string' ? payload.variationId : '';
|
|
570
|
+
const configVersion = typeof payload === 'object' && typeof payload.configVersion === 'string' ? payload.configVersion : '';
|
|
571
|
+
const readKey = `${user?.id ?? ''}:${flag.key}:${variationId}:${configVersion}`;
|
|
572
|
+
if (this.readEvaluationKeys.has(readKey))
|
|
573
|
+
return;
|
|
574
|
+
this.readEvaluationKeys.add(readKey);
|
|
575
|
+
const rawValue = flag.value === null || typeof flag.value === 'boolean' || typeof flag.value === 'number'
|
|
576
|
+
? flag.value
|
|
577
|
+
: typeof flag.value === 'string'
|
|
578
|
+
? flag.value.length > 120
|
|
579
|
+
? `${flag.value.slice(0, 117)}...`
|
|
580
|
+
: flag.value
|
|
581
|
+
: '[complex]';
|
|
582
|
+
this.enqueueTelemetryEvent({
|
|
583
|
+
name: 'betsy.system.fetch_evaluation',
|
|
584
|
+
timestamp: now,
|
|
585
|
+
userId: user?.id ?? '',
|
|
586
|
+
anonymousId: this.anonymousDeviceId,
|
|
587
|
+
properties: {
|
|
588
|
+
sdkSource: this.sdkSource,
|
|
589
|
+
flagKey: flag.key,
|
|
590
|
+
variationId: variationId || undefined,
|
|
591
|
+
enabled: typeof payload === 'object' ? payload.enabled : undefined,
|
|
592
|
+
kind: typeof payload === 'object' ? payload.kind : undefined,
|
|
593
|
+
reason: typeof payload === 'object' ? payload.reason : undefined,
|
|
594
|
+
rolloutId: typeof payload === 'object' ? payload.rolloutId : undefined,
|
|
595
|
+
rolloutStepIndex: typeof payload === 'object' ? payload.rolloutStepIndex : undefined,
|
|
596
|
+
rolloutReason: typeof payload === 'object' ? payload.rolloutReason : undefined,
|
|
597
|
+
bucketBy: typeof payload === 'object' ? payload.bucketBy : undefined,
|
|
598
|
+
source: 'sdk-read',
|
|
599
|
+
},
|
|
600
|
+
}, 'read-evaluation');
|
|
601
|
+
this.enqueueTelemetryEvent({
|
|
602
|
+
name: 'betsy.system.fetch_evaluation_result',
|
|
603
|
+
timestamp: now,
|
|
604
|
+
userId: user?.id ?? '',
|
|
605
|
+
anonymousId: this.anonymousDeviceId,
|
|
606
|
+
properties: {
|
|
607
|
+
sdkSource: this.sdkSource,
|
|
608
|
+
flagKey: flag.key,
|
|
609
|
+
variationId: variationId || undefined,
|
|
610
|
+
value: rawValue,
|
|
611
|
+
source: 'sdk-read',
|
|
612
|
+
},
|
|
613
|
+
}, 'read-evaluation-result');
|
|
614
|
+
}
|
|
615
|
+
enqueueAssignmentEvents(flags) {
|
|
616
|
+
if ((this.config?.telemetryEnabled ?? true) === false)
|
|
617
|
+
return;
|
|
618
|
+
const now = new Date().toISOString();
|
|
619
|
+
const user = this.store.getState().user;
|
|
620
|
+
for (const flag of flags) {
|
|
621
|
+
const payload = (flag.payload ?? {});
|
|
622
|
+
const experimentId = typeof payload.experimentId === 'string' ? payload.experimentId : '';
|
|
623
|
+
const experimentArmId = typeof payload.experimentArmId === 'string' ? payload.experimentArmId : '';
|
|
624
|
+
const assignmentId = typeof payload.assignmentId === 'string' ? payload.assignmentId : '';
|
|
625
|
+
const configVersion = typeof payload.configVersion === 'string' ? payload.configVersion : '';
|
|
626
|
+
if (!experimentId || !experimentArmId || !assignmentId)
|
|
627
|
+
continue;
|
|
628
|
+
const key = `${assignmentId}:${configVersion || 'noversion'}`;
|
|
629
|
+
if (this.assignmentKeys.has(key))
|
|
630
|
+
continue;
|
|
631
|
+
this.assignmentKeys.add(key);
|
|
632
|
+
this.enqueueTelemetryEvent({
|
|
633
|
+
name: 'betsy.system.assignment',
|
|
634
|
+
timestamp: now,
|
|
635
|
+
userId: user?.id ?? '',
|
|
636
|
+
anonymousId: this.anonymousDeviceId,
|
|
637
|
+
properties: {
|
|
638
|
+
sdkSource: this.sdkSource,
|
|
639
|
+
flagKey: flag.key,
|
|
640
|
+
variationId: typeof payload.variationId === 'string' ? payload.variationId : undefined,
|
|
641
|
+
experimentId,
|
|
642
|
+
experimentArmId,
|
|
643
|
+
assignmentId,
|
|
644
|
+
configVersion: configVersion || undefined,
|
|
645
|
+
source: 'sdk',
|
|
646
|
+
},
|
|
647
|
+
}, 'assignment');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async enqueueExposure(flag, options) {
|
|
651
|
+
const payload = (flag.payload ?? {});
|
|
652
|
+
const variationId = typeof payload.variationId === 'string' ? payload.variationId : '';
|
|
653
|
+
const configVersion = typeof payload.configVersion === 'string' ? payload.configVersion : '';
|
|
654
|
+
const experimentId = typeof payload.experimentId === 'string' ? payload.experimentId : '';
|
|
655
|
+
const experimentArmId = typeof payload.experimentArmId === 'string' ? payload.experimentArmId : '';
|
|
656
|
+
const assignmentId = typeof payload.assignmentId === 'string' ? payload.assignmentId : '';
|
|
657
|
+
const includeExperimentProperties = Boolean(experimentId && experimentArmId && assignmentId);
|
|
658
|
+
const user = this.store.getState().user;
|
|
659
|
+
const exposureKey = `${user?.id ?? ''}:${flag.key}:${variationId}:${configVersion}`;
|
|
660
|
+
if (this.exposureKeys.has(exposureKey))
|
|
661
|
+
return;
|
|
662
|
+
this.exposureKeys.add(exposureKey);
|
|
663
|
+
await this.track('betsy.system.exposure', {
|
|
664
|
+
sdkSource: this.sdkSource,
|
|
665
|
+
flagKey: flag.key,
|
|
666
|
+
variationId: variationId || undefined,
|
|
667
|
+
experimentId: includeExperimentProperties ? experimentId : undefined,
|
|
668
|
+
experimentArmId: includeExperimentProperties ? experimentArmId : undefined,
|
|
669
|
+
assignmentId: includeExperimentProperties ? assignmentId : undefined,
|
|
670
|
+
configVersion: configVersion || undefined,
|
|
671
|
+
reason: typeof payload.reason === 'string' ? payload.reason : undefined,
|
|
672
|
+
rolloutId: typeof payload.rolloutId === 'string' ? payload.rolloutId : undefined,
|
|
673
|
+
rolloutStepIndex: typeof payload.rolloutStepIndex === 'number' ? payload.rolloutStepIndex : undefined,
|
|
674
|
+
rolloutReason: typeof payload.rolloutReason === 'string' ? payload.rolloutReason : undefined,
|
|
675
|
+
source: 'sdk',
|
|
676
|
+
...(options?.properties ?? {}),
|
|
677
|
+
}, options?.immediate !== undefined ? { immediate: options.immediate } : undefined);
|
|
678
|
+
}
|
|
679
|
+
clearError() {
|
|
680
|
+
const state = this.store.getState();
|
|
681
|
+
if (state.error === undefined)
|
|
682
|
+
return;
|
|
683
|
+
const { error: _error, ...rest } = state;
|
|
684
|
+
this.store.setState(rest);
|
|
685
|
+
}
|
|
686
|
+
extractOfflineFallbackFlags(flags) {
|
|
687
|
+
const out = [];
|
|
688
|
+
for (const flag of flags) {
|
|
689
|
+
const payload = flag.payload;
|
|
690
|
+
const fallbackValue = payload && typeof payload === 'object'
|
|
691
|
+
? payload.offlineFallbackValue
|
|
692
|
+
: undefined;
|
|
693
|
+
if (fallbackValue === undefined)
|
|
694
|
+
continue;
|
|
695
|
+
out.push({
|
|
696
|
+
key: flag.key,
|
|
697
|
+
value: fallbackValue,
|
|
698
|
+
payload: {
|
|
699
|
+
...(payload ?? {}),
|
|
700
|
+
source: 'offline-fallback',
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
return indexFlags(out);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
//# sourceMappingURL=client.js.map
|