@statsig/js-on-device-eval-client 0.0.1-beta.10
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/.eslintrc.json +34 -0
- package/README.md +12 -0
- package/jest.config.ts +10 -0
- package/package.json +12 -0
- package/project.json +52 -0
- package/src/Errors.ts +6 -0
- package/src/EvaluationComparison.ts +189 -0
- package/src/EvaluationResult.ts +92 -0
- package/src/Evaluator.ts +493 -0
- package/src/Network.ts +30 -0
- package/src/SpecStore.ts +123 -0
- package/src/StatsigMetadataAdditions.ts +7 -0
- package/src/StatsigOnDeviceEvalClient.ts +197 -0
- package/src/StatsigOptions.ts +29 -0
- package/src/StatsigSpecsDataAdapter.ts +31 -0
- package/src/__tests__/EvaluationCallbacks.test.ts +118 -0
- package/src/__tests__/InitStrategyAwaited.test.ts +49 -0
- package/src/__tests__/InitStrategyBootstrap.test.ts +67 -0
- package/src/__tests__/InitStrategyDelayed.test.ts +65 -0
- package/src/__tests__/MockLocalStorage.ts +38 -0
- package/src/__tests__/dcs_response.json +484 -0
- package/src/index.ts +21 -0
- package/tsconfig.json +14 -0
- package/tsconfig.lib.json +10 -0
- package/tsconfig.spec.json +16 -0
- package/webpack.config.js +62 -0
package/src/Evaluator.ts
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamicConfigEvaluation,
|
|
3
|
+
EvaluationDetails,
|
|
4
|
+
GateEvaluation,
|
|
5
|
+
LayerEvaluation,
|
|
6
|
+
SecondaryExposure,
|
|
7
|
+
StatsigUserInternal,
|
|
8
|
+
getUnitIDFromUser,
|
|
9
|
+
} from '@statsig/client-core';
|
|
10
|
+
import { SHA256 } from '@statsig/sha256';
|
|
11
|
+
|
|
12
|
+
import Compare from './EvaluationComparison';
|
|
13
|
+
import {
|
|
14
|
+
EvaluationResult,
|
|
15
|
+
makeEvalResult,
|
|
16
|
+
resultToConfigEval,
|
|
17
|
+
resultToGateEval,
|
|
18
|
+
resultToLayerEval,
|
|
19
|
+
} from './EvaluationResult';
|
|
20
|
+
import SpecStore, {
|
|
21
|
+
Spec,
|
|
22
|
+
SpecAndSourceInfo,
|
|
23
|
+
SpecCondition,
|
|
24
|
+
SpecKind,
|
|
25
|
+
SpecRule,
|
|
26
|
+
} from './SpecStore';
|
|
27
|
+
|
|
28
|
+
const CONDITION_SEGMENT_COUNT = 10 * 1000;
|
|
29
|
+
const USER_BUCKET_COUNT = 1000;
|
|
30
|
+
|
|
31
|
+
type DetailedEvaluation<T> = {
|
|
32
|
+
evaluation: T | null;
|
|
33
|
+
details: EvaluationDetails;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default class Evaluator {
|
|
37
|
+
constructor(private _store: SpecStore) {}
|
|
38
|
+
|
|
39
|
+
evaluateGate(
|
|
40
|
+
name: string,
|
|
41
|
+
user: StatsigUserInternal,
|
|
42
|
+
): DetailedEvaluation<GateEvaluation> {
|
|
43
|
+
const { spec, details } = this._getSpecAndDetails('gate', name);
|
|
44
|
+
if (!spec) {
|
|
45
|
+
return { evaluation: null, details };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const evaluation = resultToGateEval(spec, this._evaluateSpec(spec, user));
|
|
49
|
+
return { evaluation, details };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
evaluateConfig(
|
|
53
|
+
name: string,
|
|
54
|
+
user: StatsigUserInternal,
|
|
55
|
+
): DetailedEvaluation<DynamicConfigEvaluation> {
|
|
56
|
+
const { spec, details } = this._getSpecAndDetails('config', name);
|
|
57
|
+
if (!spec) {
|
|
58
|
+
return { evaluation: null, details };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const evaluation = resultToConfigEval(spec, this._evaluateSpec(spec, user));
|
|
62
|
+
return { evaluation, details };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
evaluateLayer(
|
|
66
|
+
name: string,
|
|
67
|
+
user: StatsigUserInternal,
|
|
68
|
+
): DetailedEvaluation<LayerEvaluation> {
|
|
69
|
+
const { spec, details } = this._getSpecAndDetails('layer', name);
|
|
70
|
+
if (!spec) {
|
|
71
|
+
return { evaluation: null, details };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = this._evaluateSpec(spec, user);
|
|
75
|
+
const experimentName = result?.allocated_experiment_name ?? '';
|
|
76
|
+
const experimentSpec = this._store.getSpecAndSourceInfo(
|
|
77
|
+
'config',
|
|
78
|
+
experimentName,
|
|
79
|
+
).spec;
|
|
80
|
+
const evaluation = resultToLayerEval(spec, experimentSpec, result);
|
|
81
|
+
|
|
82
|
+
return { evaluation, details };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private _getSpecAndDetails(
|
|
86
|
+
kind: SpecKind,
|
|
87
|
+
name: string,
|
|
88
|
+
): { details: EvaluationDetails; spec: Spec | null } {
|
|
89
|
+
const specAndSourceInfo = this._store.getSpecAndSourceInfo(kind, name);
|
|
90
|
+
const details = this._getEvaluationDetails(specAndSourceInfo);
|
|
91
|
+
|
|
92
|
+
return { details, spec: specAndSourceInfo.spec };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private _getEvaluationDetails(info: SpecAndSourceInfo): EvaluationDetails {
|
|
96
|
+
const { source, spec, lcut, receivedAt } = info;
|
|
97
|
+
|
|
98
|
+
if (source === 'Uninitialized' || source === 'NoValues') {
|
|
99
|
+
return { reason: source };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const subreason = spec == null ? 'Unrecognized' : 'Recognized';
|
|
103
|
+
const reason = `${source}:${subreason}`;
|
|
104
|
+
|
|
105
|
+
return { reason, lcut, receivedAt };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private _evaluateSpec(
|
|
109
|
+
spec: Spec,
|
|
110
|
+
user: StatsigUserInternal,
|
|
111
|
+
): EvaluationResult {
|
|
112
|
+
const defaultValue = _isRecord(spec.defaultValue)
|
|
113
|
+
? spec.defaultValue
|
|
114
|
+
: undefined;
|
|
115
|
+
|
|
116
|
+
if (!spec.enabled) {
|
|
117
|
+
return makeEvalResult({
|
|
118
|
+
json_value: defaultValue,
|
|
119
|
+
rule_id: 'disabled',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const exposures: SecondaryExposure[] = [];
|
|
124
|
+
|
|
125
|
+
for (const rule of spec.rules) {
|
|
126
|
+
const result = this._evaluateRule(rule, user);
|
|
127
|
+
|
|
128
|
+
if (result.unsupported) {
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
exposures.push(...result.secondary_exposures);
|
|
133
|
+
|
|
134
|
+
if (!result.bool_value) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const delegateResult = this._evaluateDelegate(
|
|
139
|
+
rule.configDelegate,
|
|
140
|
+
user,
|
|
141
|
+
exposures,
|
|
142
|
+
);
|
|
143
|
+
if (delegateResult) {
|
|
144
|
+
return delegateResult;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const pass = _evalPassPercent(rule, user, spec);
|
|
148
|
+
return makeEvalResult({
|
|
149
|
+
rule_id: result.rule_id,
|
|
150
|
+
bool_value: pass,
|
|
151
|
+
json_value: pass ? result.json_value : defaultValue,
|
|
152
|
+
secondary_exposures: exposures,
|
|
153
|
+
undelegated_secondary_exposures: exposures,
|
|
154
|
+
is_experiment_group: result.is_experiment_group,
|
|
155
|
+
group_name: result.group_name,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return makeEvalResult({
|
|
160
|
+
json_value: defaultValue,
|
|
161
|
+
secondary_exposures: exposures,
|
|
162
|
+
undelegated_secondary_exposures: exposures,
|
|
163
|
+
rule_id: 'default',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private _evaluateRule(
|
|
168
|
+
rule: SpecRule,
|
|
169
|
+
user: StatsigUserInternal,
|
|
170
|
+
): EvaluationResult {
|
|
171
|
+
const exposures: SecondaryExposure[] = [];
|
|
172
|
+
let pass = true;
|
|
173
|
+
|
|
174
|
+
for (const condition of rule.conditions) {
|
|
175
|
+
const result = this._evaluateCondition(condition, user);
|
|
176
|
+
|
|
177
|
+
if (result.unsupported) {
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
exposures.push(...result.secondary_exposures);
|
|
182
|
+
|
|
183
|
+
if (!result.bool_value) {
|
|
184
|
+
pass = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return makeEvalResult({
|
|
189
|
+
rule_id: rule.id,
|
|
190
|
+
bool_value: pass,
|
|
191
|
+
json_value: _isRecord(rule.returnValue) ? rule.returnValue : undefined,
|
|
192
|
+
secondary_exposures: exposures,
|
|
193
|
+
is_experiment_group: rule.isExperimentGroup === true,
|
|
194
|
+
group_name: rule.groupName,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private _evaluateCondition(
|
|
199
|
+
condition: SpecCondition,
|
|
200
|
+
user: StatsigUserInternal,
|
|
201
|
+
): EvaluationResult {
|
|
202
|
+
let value: unknown = null;
|
|
203
|
+
let pass = false;
|
|
204
|
+
|
|
205
|
+
const field = condition.field;
|
|
206
|
+
const target = condition.targetValue;
|
|
207
|
+
const idType = condition.idType;
|
|
208
|
+
const type = condition.type;
|
|
209
|
+
|
|
210
|
+
switch (type) {
|
|
211
|
+
case 'public':
|
|
212
|
+
return makeEvalResult({ bool_value: true });
|
|
213
|
+
|
|
214
|
+
case 'pass_gate':
|
|
215
|
+
case 'fail_gate': {
|
|
216
|
+
const name = String(target);
|
|
217
|
+
const result = this._evaluateNestedGate(name, user);
|
|
218
|
+
return makeEvalResult({
|
|
219
|
+
bool_value:
|
|
220
|
+
type === 'fail_gate' ? !result.bool_value : result.bool_value,
|
|
221
|
+
secondary_exposures: result.secondary_exposures,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case 'multi_pass_gate':
|
|
226
|
+
case 'multi_fail_gate':
|
|
227
|
+
return this._evaluateMultiNestedGates(target, type, user);
|
|
228
|
+
|
|
229
|
+
case 'user_field':
|
|
230
|
+
case 'ip_based':
|
|
231
|
+
case 'ua_based':
|
|
232
|
+
value = _getFromUser(user, field);
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case 'environment_field':
|
|
236
|
+
value = _getFromEnvironment(user, field);
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case 'current_time':
|
|
240
|
+
value = Date.now();
|
|
241
|
+
break;
|
|
242
|
+
|
|
243
|
+
case 'user_bucket': {
|
|
244
|
+
const salt = String(condition.additionalValues?.['salt'] ?? '');
|
|
245
|
+
const userHash = _computeUserHash(
|
|
246
|
+
salt + '.' + getUnitIDFromUser(user, idType) ?? '',
|
|
247
|
+
);
|
|
248
|
+
value = Number(userHash % BigInt(USER_BUCKET_COUNT));
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case 'unit_id':
|
|
253
|
+
value = getUnitIDFromUser(user, idType);
|
|
254
|
+
break;
|
|
255
|
+
|
|
256
|
+
default:
|
|
257
|
+
return makeEvalResult({ unsupported: true });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const operator = condition.operator;
|
|
261
|
+
|
|
262
|
+
switch (operator) {
|
|
263
|
+
case 'gt':
|
|
264
|
+
case 'gte':
|
|
265
|
+
case 'lt':
|
|
266
|
+
case 'lte':
|
|
267
|
+
pass = Compare.compareNumbers(value, target, operator);
|
|
268
|
+
break;
|
|
269
|
+
|
|
270
|
+
case 'version_gt':
|
|
271
|
+
case 'version_gte':
|
|
272
|
+
case 'version_lt':
|
|
273
|
+
case 'version_lte':
|
|
274
|
+
case 'version_eq':
|
|
275
|
+
case 'version_neq':
|
|
276
|
+
pass = Compare.compareVersions(value, target, operator);
|
|
277
|
+
break;
|
|
278
|
+
|
|
279
|
+
case 'any':
|
|
280
|
+
case 'none':
|
|
281
|
+
case 'str_starts_with_any':
|
|
282
|
+
case 'str_ends_with_any':
|
|
283
|
+
case 'str_contains_any':
|
|
284
|
+
case 'str_contains_none':
|
|
285
|
+
case 'any_case_sensitive':
|
|
286
|
+
case 'none_case_sensitive':
|
|
287
|
+
pass = Compare.compareStringInArray(value, target, operator);
|
|
288
|
+
break;
|
|
289
|
+
|
|
290
|
+
case 'str_matches':
|
|
291
|
+
pass = Compare.compareStringWithRegEx(value, target);
|
|
292
|
+
break;
|
|
293
|
+
|
|
294
|
+
case 'before':
|
|
295
|
+
case 'after':
|
|
296
|
+
case 'on':
|
|
297
|
+
pass = Compare.compareTime(value, target, operator);
|
|
298
|
+
break;
|
|
299
|
+
|
|
300
|
+
case 'eq':
|
|
301
|
+
// eslint-disable-next-line eqeqeq
|
|
302
|
+
pass = value == target;
|
|
303
|
+
break;
|
|
304
|
+
|
|
305
|
+
case 'neq':
|
|
306
|
+
// eslint-disable-next-line eqeqeq
|
|
307
|
+
pass = value != target;
|
|
308
|
+
break;
|
|
309
|
+
|
|
310
|
+
case 'in_segment_list':
|
|
311
|
+
case 'not_in_segment_list':
|
|
312
|
+
return makeEvalResult({ unsupported: true });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return makeEvalResult({ bool_value: pass });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private _evaluateDelegate(
|
|
319
|
+
configDelegate: string | null,
|
|
320
|
+
user: StatsigUserInternal,
|
|
321
|
+
exposures: SecondaryExposure[],
|
|
322
|
+
): EvaluationResult | null {
|
|
323
|
+
if (!configDelegate) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const { spec } = this._store.getSpecAndSourceInfo('config', configDelegate);
|
|
328
|
+
if (!spec) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const result = this._evaluateSpec(spec, user);
|
|
333
|
+
return makeEvalResult({
|
|
334
|
+
...result,
|
|
335
|
+
allocated_experiment_name: configDelegate,
|
|
336
|
+
explicit_parameters: spec.explicitParameters,
|
|
337
|
+
secondary_exposures: exposures.concat(result.secondary_exposures),
|
|
338
|
+
undelegated_secondary_exposures: exposures,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private _evaluateNestedGate(
|
|
343
|
+
name: string,
|
|
344
|
+
user: StatsigUserInternal,
|
|
345
|
+
): EvaluationResult {
|
|
346
|
+
const exposures: SecondaryExposure[] = [];
|
|
347
|
+
let pass = false;
|
|
348
|
+
|
|
349
|
+
const { spec } = this._store.getSpecAndSourceInfo('gate', name);
|
|
350
|
+
if (spec) {
|
|
351
|
+
const result = this._evaluateSpec(spec, user);
|
|
352
|
+
|
|
353
|
+
if (result.unsupported) {
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
pass = result.bool_value;
|
|
358
|
+
exposures.push(...result.secondary_exposures);
|
|
359
|
+
exposures.push({
|
|
360
|
+
gate: name,
|
|
361
|
+
gateValue: String(pass),
|
|
362
|
+
ruleID: result.rule_id,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return makeEvalResult({
|
|
367
|
+
bool_value: pass,
|
|
368
|
+
secondary_exposures: exposures,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private _evaluateMultiNestedGates(
|
|
373
|
+
gates: unknown,
|
|
374
|
+
type: string,
|
|
375
|
+
user: StatsigUserInternal,
|
|
376
|
+
) {
|
|
377
|
+
if (!Array.isArray(gates)) {
|
|
378
|
+
return makeEvalResult({ unsupported: true });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const isMultiPassType = type === 'multi_pass_gate';
|
|
382
|
+
const exposures: SecondaryExposure[] = [];
|
|
383
|
+
let pass = false;
|
|
384
|
+
|
|
385
|
+
for (const name of gates) {
|
|
386
|
+
if (typeof name !== 'string') {
|
|
387
|
+
return makeEvalResult({ unsupported: true });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const result = this._evaluateNestedGate(name, user);
|
|
391
|
+
|
|
392
|
+
if (result.unsupported) {
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
exposures.push(...result.secondary_exposures);
|
|
397
|
+
|
|
398
|
+
if (
|
|
399
|
+
isMultiPassType
|
|
400
|
+
? result.bool_value === true
|
|
401
|
+
: result.bool_value === false
|
|
402
|
+
) {
|
|
403
|
+
pass = true;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return makeEvalResult({
|
|
409
|
+
bool_value: pass,
|
|
410
|
+
secondary_exposures: exposures,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function _evalPassPercent(
|
|
416
|
+
rule: SpecRule,
|
|
417
|
+
user: StatsigUserInternal,
|
|
418
|
+
config: Spec,
|
|
419
|
+
): boolean {
|
|
420
|
+
if (rule.passPercentage === 100) {
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (rule.passPercentage === 0) {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const hash = _computeUserHash(
|
|
429
|
+
config.salt +
|
|
430
|
+
'.' +
|
|
431
|
+
(rule.salt ?? rule.id) +
|
|
432
|
+
'.' +
|
|
433
|
+
(getUnitIDFromUser(user, rule.idType) ?? ''),
|
|
434
|
+
);
|
|
435
|
+
return (
|
|
436
|
+
Number(hash % BigInt(CONDITION_SEGMENT_COUNT)) < rule.passPercentage * 100
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function _computeUserHash(userHash: string): bigint {
|
|
441
|
+
const sha256 = SHA256(userHash);
|
|
442
|
+
return sha256.dataView().getBigUint64(0, false);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function _getFromEnvironment(
|
|
446
|
+
user: StatsigUserInternal,
|
|
447
|
+
field: string | null,
|
|
448
|
+
): unknown {
|
|
449
|
+
if (field == null) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
return _getParameterCaseInsensitive(user.statsigEnvironment, field);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function _getParameterCaseInsensitive(
|
|
456
|
+
object: Record<string, unknown> | undefined | null,
|
|
457
|
+
key: string,
|
|
458
|
+
): unknown {
|
|
459
|
+
if (object == null) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
const asLowercase = key.toLowerCase();
|
|
463
|
+
const keyMatch = Object.keys(object).find(
|
|
464
|
+
(k) => k.toLowerCase() === asLowercase,
|
|
465
|
+
);
|
|
466
|
+
if (keyMatch === undefined) {
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
return object[keyMatch];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function _getFromUser(
|
|
473
|
+
user: StatsigUserInternal,
|
|
474
|
+
field: string | null,
|
|
475
|
+
): unknown {
|
|
476
|
+
if (field == null || typeof user !== 'object' || user == null) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const indexableUser = user as { [field: string]: unknown };
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
indexableUser[field] ??
|
|
483
|
+
indexableUser[field.toLowerCase()] ??
|
|
484
|
+
user?.custom?.[field] ??
|
|
485
|
+
user?.custom?.[field.toLowerCase()] ??
|
|
486
|
+
user?.privateAttributes?.[field] ??
|
|
487
|
+
user?.privateAttributes?.[field.toLowerCase()]
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function _isRecord(obj: unknown): obj is Record<string, unknown> {
|
|
492
|
+
return obj != null && typeof obj === 'object';
|
|
493
|
+
}
|
package/src/Network.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { NetworkCore, _getOverridableUrl } from '@statsig/client-core';
|
|
2
|
+
|
|
3
|
+
import { StatsigOptions } from './StatsigOptions';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_API = 'https://api.statsigcdn.com/v1';
|
|
6
|
+
const DEFAULT_ENDPOINT = '/download_config_specs';
|
|
7
|
+
|
|
8
|
+
export default class StatsigNetwork extends NetworkCore {
|
|
9
|
+
private _downloadConfigSpecsUrl: string;
|
|
10
|
+
|
|
11
|
+
constructor(options: StatsigOptions | null = null) {
|
|
12
|
+
super(options);
|
|
13
|
+
|
|
14
|
+
this._downloadConfigSpecsUrl = _getOverridableUrl(
|
|
15
|
+
options?.downloadConfigSpecsUrl,
|
|
16
|
+
options?.api,
|
|
17
|
+
DEFAULT_ENDPOINT,
|
|
18
|
+
DEFAULT_API,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async fetchConfigSpecs(sdkKey: string): Promise<string | null> {
|
|
23
|
+
const response = await this.get({
|
|
24
|
+
sdkKey: sdkKey,
|
|
25
|
+
url: this._downloadConfigSpecsUrl,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return response?.body ?? null;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/SpecStore.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DataAdapterResult,
|
|
3
|
+
DataSource,
|
|
4
|
+
typedJsonParse,
|
|
5
|
+
} from '@statsig/client-core';
|
|
6
|
+
|
|
7
|
+
export type SpecCondition = {
|
|
8
|
+
type: string;
|
|
9
|
+
targetValue: unknown;
|
|
10
|
+
operator: string | null;
|
|
11
|
+
field: string | null;
|
|
12
|
+
additionalValues: Record<string, unknown> | null;
|
|
13
|
+
idType: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SpecRule = {
|
|
17
|
+
name: string;
|
|
18
|
+
passPercentage: number;
|
|
19
|
+
conditions: SpecCondition[];
|
|
20
|
+
returnValue: unknown;
|
|
21
|
+
id: string;
|
|
22
|
+
salt: string;
|
|
23
|
+
idType: string;
|
|
24
|
+
configDelegate: string | null;
|
|
25
|
+
isExperimentGroup?: boolean;
|
|
26
|
+
groupName?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type Spec = {
|
|
30
|
+
name: string;
|
|
31
|
+
type: string;
|
|
32
|
+
salt: string;
|
|
33
|
+
defaultValue: unknown;
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
idType: string;
|
|
36
|
+
rules: SpecRule[];
|
|
37
|
+
entity: string;
|
|
38
|
+
explicitParameters: string[] | null;
|
|
39
|
+
hasSharedParams: boolean;
|
|
40
|
+
isActive?: boolean;
|
|
41
|
+
targetAppIDs?: string[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type DownloadConfigSpecsResponse = {
|
|
45
|
+
feature_gates: Spec[];
|
|
46
|
+
dynamic_configs: Spec[];
|
|
47
|
+
layer_configs: Spec[];
|
|
48
|
+
time: number;
|
|
49
|
+
has_updates: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type SpecAndSourceInfo = {
|
|
53
|
+
spec: Spec | null;
|
|
54
|
+
source: DataSource;
|
|
55
|
+
lcut: number;
|
|
56
|
+
receivedAt: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type SpecKind = 'gate' | 'config' | 'layer';
|
|
60
|
+
|
|
61
|
+
export default class SpecStore {
|
|
62
|
+
private _values: DownloadConfigSpecsResponse | null = null;
|
|
63
|
+
private _source: DataSource = 'Uninitialized';
|
|
64
|
+
private _lcut = 0;
|
|
65
|
+
private _receivedAt = 0;
|
|
66
|
+
|
|
67
|
+
setValuesFromDataAdapter(result: DataAdapterResult | null): void {
|
|
68
|
+
if (!result) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const values = typedJsonParse<DownloadConfigSpecsResponse>(
|
|
73
|
+
result.data,
|
|
74
|
+
'has_updates',
|
|
75
|
+
'Failed to parse DownloadConfigSpecsResponse',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (values?.has_updates !== true) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this._lcut = values.time;
|
|
83
|
+
this._receivedAt = result.receivedAt;
|
|
84
|
+
this._source = result.source;
|
|
85
|
+
this._values = values;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
reset(): void {
|
|
89
|
+
this._values = null;
|
|
90
|
+
this._source = 'Loading';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
finalize(): void {
|
|
94
|
+
if (this._values) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this._source = 'NoValues';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getSpecAndSourceInfo(kind: SpecKind, name: string): SpecAndSourceInfo {
|
|
102
|
+
// todo: use Object instead of Array
|
|
103
|
+
const specs = this._getSpecs(kind);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
spec: specs?.find((spec) => spec.name === name) ?? null,
|
|
107
|
+
source: this._source,
|
|
108
|
+
lcut: this._lcut,
|
|
109
|
+
receivedAt: this._receivedAt,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private _getSpecs(kind: SpecKind) {
|
|
114
|
+
switch (kind) {
|
|
115
|
+
case 'gate':
|
|
116
|
+
return this._values?.feature_gates;
|
|
117
|
+
case 'config':
|
|
118
|
+
return this._values?.dynamic_configs;
|
|
119
|
+
case 'layer':
|
|
120
|
+
return this._values?.layer_configs;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|