@ops-ai/react-router-toggly 1.1.0 → 1.2.1
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/CHANGELOG.md +16 -0
- package/dist/client.cjs +81 -24
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +26 -0
- package/dist/client.d.ts +26 -0
- package/dist/client.js +81 -24
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +257 -109
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +49 -5
- package/dist/server.d.ts +49 -5
- package/dist/server.js +257 -109
- package/dist/server.js.map +1 -1
- package/dist/telemetry/grpc.cjs +1 -1
- package/dist/telemetry/grpc.cjs.map +1 -1
- package/dist/telemetry/grpc.js +1 -1
- package/dist/telemetry/grpc.js.map +1 -1
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { normalizeEntityContext, registerContext, appendEvaluationContext } from '@ops-ai/toggly-hooks-types';
|
|
2
2
|
import { fromHttpRequest, snapshotEvaluatedBooleans, parseDefinitionsPayload, evaluateDefinitions } from '@ops-ai/toggly-eval';
|
|
3
3
|
export { fromHttpRequest } from '@ops-ai/toggly-eval';
|
|
4
|
+
import { readResponseBody, parseEvaluatedResponseBody, asVariantDefsRecord, unwrapDefsPayload } from '@ops-ai/toggly-signed-defs';
|
|
4
5
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
5
6
|
import { buildFlagGateIndex, applyLocalGate } from '@ops-ai/toggly-local-gates';
|
|
6
7
|
import WebSocket from 'ws';
|
|
7
|
-
import { readResponseBody, parseEvaluatedResponseBody } from '@ops-ai/toggly-signed-defs';
|
|
8
8
|
import { createRequire } from 'module';
|
|
9
9
|
import fs from 'fs';
|
|
10
10
|
import path from 'path';
|
|
@@ -54,28 +54,49 @@ function mergeConfig(config) {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
function buildDefinitionsUrl(config, context) {
|
|
57
|
-
const { baseUrl, appKey, environment, groups, claims, evaluationMode } = mergeConfig(config);
|
|
57
|
+
const { baseUrl, appKey, environment, groups, claims, evaluationMode, enableVariants } = mergeConfig(config);
|
|
58
58
|
if (!appKey) {
|
|
59
59
|
throw new Error("appKey is required");
|
|
60
60
|
}
|
|
61
61
|
const mode = evaluationMode ?? "remote";
|
|
62
|
-
const
|
|
62
|
+
const useVariants = mode !== "local" && enableVariants === true;
|
|
63
|
+
let pathSegment = "evaluated-signed";
|
|
64
|
+
if (mode === "local") {
|
|
65
|
+
pathSegment = "definitions-signed";
|
|
66
|
+
} else if (useVariants) {
|
|
67
|
+
pathSegment = "evaluated-variants-signed";
|
|
68
|
+
}
|
|
63
69
|
const url = new URL(`${baseUrl}/${pathSegment}/${appKey}/${environment}`);
|
|
64
70
|
if (mode === "local") {
|
|
65
71
|
return url.toString();
|
|
66
72
|
}
|
|
67
|
-
const fromParam = context;
|
|
73
|
+
const fromParam = typeof context === "string" ? { identity: context } : context;
|
|
68
74
|
appendEvaluationContext(
|
|
69
75
|
url,
|
|
70
76
|
{
|
|
71
77
|
identity: fromParam?.identity,
|
|
72
|
-
groups: groups,
|
|
73
|
-
claims: claims
|
|
78
|
+
groups: fromParam?.groups ?? groups,
|
|
79
|
+
claims: fromParam?.claims ?? claims
|
|
74
80
|
},
|
|
75
|
-
"evaluated"
|
|
81
|
+
useVariants ? "variants" : "evaluated"
|
|
76
82
|
);
|
|
77
83
|
return url.toString();
|
|
78
84
|
}
|
|
85
|
+
function parseVariantDefsPayload(parsed) {
|
|
86
|
+
return asVariantDefsRecord(unwrapDefsPayload(parsed));
|
|
87
|
+
}
|
|
88
|
+
function variantDefsToFlags(defs) {
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const key of Object.keys(defs)) {
|
|
91
|
+
out[key] = defs[key]?.enabled === true;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
function decodeVariantValue(value, isT) {
|
|
96
|
+
if (value === null || value === void 0) return null;
|
|
97
|
+
if (isT) return isT(value) ? value : null;
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
79
100
|
function isFeatureEnabledLocal(defsByKey, featureKey, evalCtx = {}, defaultValue = false) {
|
|
80
101
|
if (!defsByKey || defsByKey.size === 0) {
|
|
81
102
|
return defaultValue;
|
|
@@ -304,10 +325,7 @@ var UsageBatcher = class {
|
|
|
304
325
|
featureCount() {
|
|
305
326
|
return this.perFeature.size;
|
|
306
327
|
}
|
|
307
|
-
|
|
308
|
-
if (this.isEmpty()) {
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
328
|
+
buildPayload() {
|
|
311
329
|
const payload = {
|
|
312
330
|
appKey: this.appKey,
|
|
313
331
|
environment: this.environment,
|
|
@@ -317,57 +335,60 @@ var UsageBatcher = class {
|
|
|
317
335
|
uniqueUserHashes: [...this.appUnique],
|
|
318
336
|
processStartTime: toProtobufTimestamp(this.processStartTime)
|
|
319
337
|
};
|
|
320
|
-
if (this.instanceName)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (this.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
338
|
+
if (this.instanceName) payload.instanceName = this.instanceName;
|
|
339
|
+
if (this.appVersion) payload.appVersion = this.appVersion;
|
|
340
|
+
if (this.definitionCacheHits > 0) payload.definitionCacheHits = this.definitionCacheHits;
|
|
341
|
+
if (this.definitionCacheMisses > 0) payload.definitionCacheMisses = this.definitionCacheMisses;
|
|
342
|
+
return payload;
|
|
343
|
+
}
|
|
344
|
+
appendFeatureStat(payload, snapshots, feature, agg) {
|
|
345
|
+
const variantStats = {};
|
|
346
|
+
for (const [name, stats] of agg.variantStats) {
|
|
347
|
+
if (stats.checkCount > 0 || stats.requestCount > 0 || stats.usedCount > 0 || stats.viewedCount > 0) {
|
|
348
|
+
variantStats[name] = { ...stats };
|
|
349
|
+
}
|
|
331
350
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
351
|
+
if (agg.uniqueUsersEnabled.size > 0) snapshots.uniqueUsersEnabled[feature] = [...agg.uniqueUsersEnabled];
|
|
352
|
+
if (agg.uniqueUsersDisabled.size > 0) snapshots.uniqueUsersDisabled[feature] = [...agg.uniqueUsersDisabled];
|
|
353
|
+
if (agg.uniqueUsersUsed.size > 0) snapshots.uniqueUsersUsed[feature] = [...agg.uniqueUsersUsed];
|
|
354
|
+
payload.stats.push({
|
|
355
|
+
feature,
|
|
356
|
+
uniqueContextIdentifierEnabledCount: agg.uniqueUsersEnabled.size,
|
|
357
|
+
uniqueContextIdentifierDisabledCount: agg.uniqueUsersDisabled.size,
|
|
358
|
+
uniqueUsersUsedCount: agg.uniqueUsersUsed.size,
|
|
359
|
+
uniqueUserHashes: [...agg.uniqueUserHashes],
|
|
360
|
+
uniqueViewedUserHashes: [...agg.uniqueViewedUserHashes],
|
|
361
|
+
variantStats
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
buildIdentitySnapshots(payload) {
|
|
365
|
+
const snapshots = {
|
|
366
|
+
uniqueUsersEnabled: {},
|
|
367
|
+
uniqueUsersDisabled: {},
|
|
368
|
+
uniqueUsersUsed: {}
|
|
369
|
+
};
|
|
335
370
|
for (const [feature, agg] of this.perFeature) {
|
|
336
|
-
|
|
337
|
-
for (const [name, stats] of agg.variantStats) {
|
|
338
|
-
if (stats.checkCount > 0 || stats.requestCount > 0 || stats.usedCount > 0 || stats.viewedCount > 0) {
|
|
339
|
-
variantStats[name] = { ...stats };
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
if (agg.uniqueUsersEnabled.size > 0) {
|
|
343
|
-
uniqueUsersEnabled[feature] = [...agg.uniqueUsersEnabled];
|
|
344
|
-
}
|
|
345
|
-
if (agg.uniqueUsersDisabled.size > 0) {
|
|
346
|
-
uniqueUsersDisabled[feature] = [...agg.uniqueUsersDisabled];
|
|
347
|
-
}
|
|
348
|
-
if (agg.uniqueUsersUsed.size > 0) {
|
|
349
|
-
uniqueUsersUsed[feature] = [...agg.uniqueUsersUsed];
|
|
350
|
-
}
|
|
351
|
-
payload.stats.push({
|
|
352
|
-
feature,
|
|
353
|
-
uniqueContextIdentifierEnabledCount: agg.uniqueUsersEnabled.size,
|
|
354
|
-
uniqueContextIdentifierDisabledCount: agg.uniqueUsersDisabled.size,
|
|
355
|
-
uniqueUsersUsedCount: agg.uniqueUsersUsed.size,
|
|
356
|
-
uniqueUserHashes: [...agg.uniqueUserHashes],
|
|
357
|
-
uniqueViewedUserHashes: [...agg.uniqueViewedUserHashes],
|
|
358
|
-
variantStats
|
|
359
|
-
});
|
|
371
|
+
this.appendFeatureStat(payload, snapshots, feature, agg);
|
|
360
372
|
}
|
|
373
|
+
return snapshots;
|
|
374
|
+
}
|
|
375
|
+
reset() {
|
|
361
376
|
this.perFeature = /* @__PURE__ */ new Map();
|
|
362
377
|
this.appUnique = /* @__PURE__ */ new Set();
|
|
363
378
|
this.droppedFeatures = false;
|
|
364
379
|
this.definitionCacheHits = 0;
|
|
365
380
|
this.definitionCacheMisses = 0;
|
|
381
|
+
}
|
|
382
|
+
buildAndReset() {
|
|
383
|
+
if (this.isEmpty()) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
const payload = this.buildPayload();
|
|
387
|
+
const snapshots = this.buildIdentitySnapshots(payload);
|
|
388
|
+
this.reset();
|
|
366
389
|
return {
|
|
367
390
|
payload,
|
|
368
|
-
|
|
369
|
-
uniqueUsersDisabled,
|
|
370
|
-
uniqueUsersUsed
|
|
391
|
+
...snapshots
|
|
371
392
|
};
|
|
372
393
|
}
|
|
373
394
|
restoreFromBundle(bundle) {
|
|
@@ -558,7 +579,7 @@ var MetricsBatcher = class {
|
|
|
558
579
|
|
|
559
580
|
// src/core/sdk-identity.ts
|
|
560
581
|
var SDK_ID = "react-router";
|
|
561
|
-
var SDK_VERSION = "1.1
|
|
582
|
+
var SDK_VERSION = "1.2.1";
|
|
562
583
|
function sdkUserAgent() {
|
|
563
584
|
return `toggly-${SDK_ID}/${SDK_VERSION}`;
|
|
564
585
|
}
|
|
@@ -1078,7 +1099,7 @@ async function extractEvalContext(request, providers = {}) {
|
|
|
1078
1099
|
|
|
1079
1100
|
// src/server/sdk-identity.ts
|
|
1080
1101
|
var SDK_ID2 = "react-router";
|
|
1081
|
-
var SDK_VERSION2 = "1.1
|
|
1102
|
+
var SDK_VERSION2 = "1.2.1";
|
|
1082
1103
|
var SDK_HEADER_ID = "X-Toggly-Sdk";
|
|
1083
1104
|
var SDK_HEADER_VERSION = "X-Toggly-Sdk-Version";
|
|
1084
1105
|
function sdkUserAgent2() {
|
|
@@ -1370,12 +1391,19 @@ var TogglyServerClient = class {
|
|
|
1370
1391
|
this.flags = {};
|
|
1371
1392
|
/** Raw definitions-signed models keyed by featureKey. */
|
|
1372
1393
|
this.definitions = /* @__PURE__ */ new Map();
|
|
1394
|
+
/** Evaluated-variants-signed defs when `enableVariants`; otherwise null. */
|
|
1395
|
+
this.variants = null;
|
|
1373
1396
|
this.hooks = [];
|
|
1374
1397
|
this.initialized = false;
|
|
1375
1398
|
/** Dedupes concurrent cold-start definition fetches. */
|
|
1376
1399
|
this.definitionsLoadPromise = null;
|
|
1377
1400
|
/** Single-flight for definition GET so concurrent joiners are not double-counted. */
|
|
1378
1401
|
this.definitionsFetchInFlight = null;
|
|
1402
|
+
/**
|
|
1403
|
+
* Serializes variants-mode init so concurrent identities cannot interleave
|
|
1404
|
+
* shared `this.variants` / `this.flags` mutations.
|
|
1405
|
+
*/
|
|
1406
|
+
this.variantsInitChain = Promise.resolve();
|
|
1379
1407
|
// WebSocket live updates
|
|
1380
1408
|
this.ws = null;
|
|
1381
1409
|
this.wsConnected = false;
|
|
@@ -1395,7 +1423,7 @@ var TogglyServerClient = class {
|
|
|
1395
1423
|
...config,
|
|
1396
1424
|
...telemetryFlags,
|
|
1397
1425
|
...grpcClients,
|
|
1398
|
-
evaluationMode: "local",
|
|
1426
|
+
evaluationMode: config.enableVariants ? "remote" : "local",
|
|
1399
1427
|
telemetryTransport: config.telemetryTransport ?? "grpc",
|
|
1400
1428
|
telemetryAttachProcessHandlers: config.telemetryAttachProcessHandlers ?? (config.telemetryTransport ?? "grpc") === "grpc"
|
|
1401
1429
|
});
|
|
@@ -1439,12 +1467,12 @@ var TogglyServerClient = class {
|
|
|
1439
1467
|
});
|
|
1440
1468
|
this.telemetry.start();
|
|
1441
1469
|
}
|
|
1442
|
-
recordCheckForKey(featureKey, enabled, identityOverride) {
|
|
1470
|
+
recordCheckForKey(featureKey, enabled, identityOverride, variant) {
|
|
1443
1471
|
if (!this.telemetry?.usageEnabled) {
|
|
1444
1472
|
return;
|
|
1445
1473
|
}
|
|
1446
1474
|
const identity = identityOverride?.identity ?? this.identity;
|
|
1447
|
-
this.telemetry.recordCheck(featureKey, enabled, identity);
|
|
1475
|
+
this.telemetry.recordCheck(featureKey, enabled, identity, variant);
|
|
1448
1476
|
}
|
|
1449
1477
|
/** Soft-fail wrapper — definition refresh must not throw from telemetry. */
|
|
1450
1478
|
noteDefinitionCacheHit() {
|
|
@@ -1556,6 +1584,9 @@ var TogglyServerClient = class {
|
|
|
1556
1584
|
* shared mutable `this.flags` / `this.identity` (safe under concurrency).
|
|
1557
1585
|
*/
|
|
1558
1586
|
snapshotFlags(identityOverride) {
|
|
1587
|
+
if (this.config.enableVariants) {
|
|
1588
|
+
return { ...this.flags };
|
|
1589
|
+
}
|
|
1559
1590
|
return {
|
|
1560
1591
|
...this.config.featureDefaults ?? {},
|
|
1561
1592
|
...snapshotEvaluatedBooleans(
|
|
@@ -1565,12 +1596,18 @@ var TogglyServerClient = class {
|
|
|
1565
1596
|
};
|
|
1566
1597
|
}
|
|
1567
1598
|
getEffectiveFlag(featureKey, defaultValue = false, entityContext, identityOverride) {
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
featureKey
|
|
1571
|
-
this.
|
|
1572
|
-
|
|
1573
|
-
|
|
1599
|
+
let evaluated;
|
|
1600
|
+
if (this.config.enableVariants) {
|
|
1601
|
+
const raw = this.flags[featureKey];
|
|
1602
|
+
evaluated = typeof raw === "boolean" ? raw : this.config.featureDefaults?.[featureKey] ?? defaultValue;
|
|
1603
|
+
} else {
|
|
1604
|
+
evaluated = isFeatureEnabledLocal(
|
|
1605
|
+
this.definitions,
|
|
1606
|
+
featureKey,
|
|
1607
|
+
this.buildEvalContext(identityOverride, entityContext),
|
|
1608
|
+
this.config.featureDefaults?.[featureKey] ?? defaultValue
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1574
1611
|
return applyLocalGate(evaluated, featureKey, this.localGates, this.localGateIndex);
|
|
1575
1612
|
}
|
|
1576
1613
|
getDefinitionsRevision() {
|
|
@@ -1647,13 +1684,56 @@ var TogglyServerClient = class {
|
|
|
1647
1684
|
return this.wsConnected;
|
|
1648
1685
|
}
|
|
1649
1686
|
/**
|
|
1650
|
-
* Initialize the client
|
|
1651
|
-
*
|
|
1652
|
-
*
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1687
|
+
* Initialize the client and optionally bind identity.
|
|
1688
|
+
* Local mode: concurrent cold starts share one definitions fetch; each caller
|
|
1689
|
+
* still receives a request-local snapshot for its identity.
|
|
1690
|
+
* Variants mode: inits are serialized so concurrent identities cannot
|
|
1691
|
+
* interleave remote variant assignments on the shared client.
|
|
1655
1692
|
*/
|
|
1656
1693
|
async init(identity) {
|
|
1694
|
+
if (this.config.enableVariants) {
|
|
1695
|
+
return this.initWithVariants(identity);
|
|
1696
|
+
}
|
|
1697
|
+
return this.initWithLocalDefinitions(identity);
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Variants are remote-evaluated; identity must be bound before fetch so
|
|
1701
|
+
* `/evaluated-variants-signed` receives the correct `userId`.
|
|
1702
|
+
*/
|
|
1703
|
+
async initWithVariants(identity) {
|
|
1704
|
+
const run = async () => {
|
|
1705
|
+
const previousIdentity = this.identity;
|
|
1706
|
+
const wasWarm = this.initialized && this.variants !== null;
|
|
1707
|
+
const identityChanged = previousIdentity !== identity;
|
|
1708
|
+
this.identity = identity;
|
|
1709
|
+
if (!wasWarm || identityChanged) {
|
|
1710
|
+
if (identityChanged && this.definitionsFetchInFlight) {
|
|
1711
|
+
await this.definitionsFetchInFlight.catch(() => void 0);
|
|
1712
|
+
}
|
|
1713
|
+
await this.fetchFlags();
|
|
1714
|
+
this.initialized = true;
|
|
1715
|
+
if (!wasWarm) {
|
|
1716
|
+
this.startWebSocket();
|
|
1717
|
+
if (identity) {
|
|
1718
|
+
await this.executeBeforeIdentify(identity);
|
|
1719
|
+
await this.executeAfterIdentify(identity);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
this.flags = this.snapshotFlags({ identity });
|
|
1724
|
+
if (wasWarm && !identityChanged) {
|
|
1725
|
+
this.logger.debug("Client already initialized; re-snapshotted for identity.");
|
|
1726
|
+
}
|
|
1727
|
+
return { ...this.flags };
|
|
1728
|
+
};
|
|
1729
|
+
const result = this.variantsInitChain.then(run, run);
|
|
1730
|
+
this.variantsInitChain = result.then(
|
|
1731
|
+
() => void 0,
|
|
1732
|
+
() => void 0
|
|
1733
|
+
);
|
|
1734
|
+
return result;
|
|
1735
|
+
}
|
|
1736
|
+
async initWithLocalDefinitions(identity) {
|
|
1657
1737
|
const wasWarm = this.initialized && this.definitions.size > 0;
|
|
1658
1738
|
if (!wasWarm) {
|
|
1659
1739
|
if (!this.definitionsLoadPromise) {
|
|
@@ -1697,38 +1777,98 @@ var TogglyServerClient = class {
|
|
|
1697
1777
|
this.definitionsFetchInFlight = null;
|
|
1698
1778
|
}
|
|
1699
1779
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1780
|
+
applyNoAppKeyDefaults() {
|
|
1781
|
+
this.logger.debug("No appKey, using featureDefaults.");
|
|
1782
|
+
this.definitions = /* @__PURE__ */ new Map();
|
|
1783
|
+
this.variants = this.config.enableVariants ? {} : null;
|
|
1784
|
+
this.flags = this.config.featureDefaults ?? {};
|
|
1785
|
+
return this.flags;
|
|
1786
|
+
}
|
|
1787
|
+
applyParsedDefinitionsBody(parsed, useVariants) {
|
|
1788
|
+
if (useVariants) {
|
|
1789
|
+
this.variants = parseVariantDefsPayload(parsed);
|
|
1703
1790
|
this.definitions = /* @__PURE__ */ new Map();
|
|
1791
|
+
this.flags = {
|
|
1792
|
+
...this.config.featureDefaults,
|
|
1793
|
+
...variantDefsToFlags(this.variants)
|
|
1794
|
+
};
|
|
1795
|
+
this.logger.debug(`Fetched ${Object.keys(this.variants).length} variant defs.`);
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
this.variants = null;
|
|
1799
|
+
this.definitions = parseDefinitionsPayload(parsed);
|
|
1800
|
+
this.flags = {
|
|
1801
|
+
...this.config.featureDefaults,
|
|
1802
|
+
...snapshotEvaluatedBooleans(this.definitions, this.buildEvalContext())
|
|
1803
|
+
};
|
|
1804
|
+
this.logger.debug(`Fetched ${this.definitions.size} definitions.`);
|
|
1805
|
+
}
|
|
1806
|
+
hasLastKnownGoodDefinitions(useVariants) {
|
|
1807
|
+
if (this.definitions.size > 0) {
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
return Boolean(useVariants && this.variants && Object.keys(this.variants).length > 0);
|
|
1811
|
+
}
|
|
1812
|
+
buildDefinitionsFetchUrl(useVariants, pin) {
|
|
1813
|
+
const identityContext = useVariants && this.identity ? { identity: this.identity } : void 0;
|
|
1814
|
+
return appendDefinitionsRevisionParam(
|
|
1815
|
+
buildDefinitionsUrl(this.config, identityContext),
|
|
1816
|
+
pin
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
noteNotModifiedResponse(responseRevision) {
|
|
1820
|
+
if (responseRevision) {
|
|
1821
|
+
this.cacheDefinitionsRevision(responseRevision);
|
|
1822
|
+
}
|
|
1823
|
+
this.noteDefinitionCacheHit();
|
|
1824
|
+
this.logger.debug("Definitions unchanged (304)");
|
|
1825
|
+
return this.flags;
|
|
1826
|
+
}
|
|
1827
|
+
noteAppliedDefinitionsRevision(previousRevision, responseRevision) {
|
|
1828
|
+
if (responseRevision) {
|
|
1829
|
+
this.cacheDefinitionsRevision(responseRevision);
|
|
1830
|
+
}
|
|
1831
|
+
if (this.definitionsRevisionsMatch(previousRevision, responseRevision)) {
|
|
1832
|
+
this.noteDefinitionCacheHit();
|
|
1833
|
+
} else {
|
|
1834
|
+
this.noteDefinitionCacheMiss();
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
recoverDefinitionsFetchError(error, outcomeRecorded, useVariants) {
|
|
1838
|
+
this.logger.warn(
|
|
1839
|
+
"Failed to fetch flags, preserving last-known-good flags when available.",
|
|
1840
|
+
error
|
|
1841
|
+
);
|
|
1842
|
+
this.config.onError?.("Error fetching feature flags", error);
|
|
1843
|
+
if (!outcomeRecorded && this.hasLastKnownGoodDefinitions(useVariants)) {
|
|
1844
|
+
this.noteDefinitionCacheHit();
|
|
1845
|
+
}
|
|
1846
|
+
if (Object.keys(this.flags).length === 0) {
|
|
1704
1847
|
this.flags = this.config.featureDefaults ?? {};
|
|
1705
|
-
|
|
1848
|
+
}
|
|
1849
|
+
return this.flags;
|
|
1850
|
+
}
|
|
1851
|
+
async performDefinitionsFetch() {
|
|
1852
|
+
if (!this.config.appKey) {
|
|
1853
|
+
return this.applyNoAppKeyDefaults();
|
|
1706
1854
|
}
|
|
1707
1855
|
let outcomeRecorded = false;
|
|
1856
|
+
const useVariants = this.config.enableVariants === true;
|
|
1708
1857
|
try {
|
|
1709
1858
|
const pin = this.pendingDefinitionsPin;
|
|
1710
1859
|
this.pendingDefinitionsPin = null;
|
|
1711
|
-
const url =
|
|
1712
|
-
buildDefinitionsUrl(this.config),
|
|
1713
|
-
pin
|
|
1714
|
-
);
|
|
1860
|
+
const url = this.buildDefinitionsFetchUrl(useVariants, pin);
|
|
1715
1861
|
this.logger.debug(`Fetching definitions from: ${url}`);
|
|
1716
1862
|
const previousRevision = pin ? null : this.getDefinitionsRevision();
|
|
1717
|
-
const
|
|
1718
|
-
|
|
1719
|
-
);
|
|
1863
|
+
const conditionalHeaders = previousRevision ? { "If-None-Match": previousRevision } : void 0;
|
|
1864
|
+
const headers = buildDefinitionFetchHeaders(conditionalHeaders);
|
|
1720
1865
|
const response = await fetchWithTimeout(url, { headers }, this.config.timeout);
|
|
1721
1866
|
const responseRevision = this.normalizeDefinitionsRevision(
|
|
1722
1867
|
extractDefinitionsRevision(response)
|
|
1723
1868
|
);
|
|
1724
1869
|
if (response.status === 304) {
|
|
1725
|
-
if (responseRevision) {
|
|
1726
|
-
this.cacheDefinitionsRevision(responseRevision);
|
|
1727
|
-
}
|
|
1728
|
-
this.noteDefinitionCacheHit();
|
|
1729
1870
|
outcomeRecorded = true;
|
|
1730
|
-
this.
|
|
1731
|
-
return this.flags;
|
|
1871
|
+
return this.noteNotModifiedResponse(responseRevision);
|
|
1732
1872
|
}
|
|
1733
1873
|
if (!response.ok) {
|
|
1734
1874
|
throw new TogglyNetworkError(
|
|
@@ -1741,35 +1881,15 @@ var TogglyServerClient = class {
|
|
|
1741
1881
|
baseUrl: this.config.baseUrl ?? "https://definitions.toggly.io",
|
|
1742
1882
|
allowedKeyIds: this.config.allowedKeyIds,
|
|
1743
1883
|
maxSignatureAgeSeconds: this.config.maxSignatureAgeSeconds,
|
|
1744
|
-
headers: buildDefinitionFetchHeaders(
|
|
1884
|
+
headers: buildDefinitionFetchHeaders()
|
|
1745
1885
|
});
|
|
1746
|
-
this.
|
|
1747
|
-
this.
|
|
1748
|
-
...this.config.featureDefaults ?? {},
|
|
1749
|
-
...snapshotEvaluatedBooleans(this.definitions, this.buildEvalContext())
|
|
1750
|
-
};
|
|
1751
|
-
if (responseRevision) {
|
|
1752
|
-
this.cacheDefinitionsRevision(responseRevision);
|
|
1753
|
-
}
|
|
1754
|
-
this.logger.debug(`Fetched ${this.definitions.size} definitions.`);
|
|
1755
|
-
if (this.definitionsRevisionsMatch(previousRevision, responseRevision)) {
|
|
1756
|
-
this.noteDefinitionCacheHit();
|
|
1757
|
-
} else {
|
|
1758
|
-
this.noteDefinitionCacheMiss();
|
|
1759
|
-
}
|
|
1886
|
+
this.applyParsedDefinitionsBody(parsed, useVariants);
|
|
1887
|
+
this.noteAppliedDefinitionsRevision(previousRevision, responseRevision);
|
|
1760
1888
|
outcomeRecorded = true;
|
|
1761
1889
|
await this.executeAfterRefresh(this.flags);
|
|
1762
1890
|
return this.flags;
|
|
1763
1891
|
} catch (error) {
|
|
1764
|
-
this.
|
|
1765
|
-
this.config.onError?.("Error fetching feature flags", error);
|
|
1766
|
-
if (!outcomeRecorded && this.definitions.size > 0) {
|
|
1767
|
-
this.noteDefinitionCacheHit();
|
|
1768
|
-
}
|
|
1769
|
-
if (Object.keys(this.flags).length === 0) {
|
|
1770
|
-
this.flags = this.config.featureDefaults ?? {};
|
|
1771
|
-
}
|
|
1772
|
-
return this.flags;
|
|
1892
|
+
return this.recoverDefinitionsFetchError(error, outcomeRecorded, useVariants);
|
|
1773
1893
|
}
|
|
1774
1894
|
}
|
|
1775
1895
|
/**
|
|
@@ -1778,6 +1898,34 @@ var TogglyServerClient = class {
|
|
|
1778
1898
|
getFlags() {
|
|
1779
1899
|
return { ...this.flags };
|
|
1780
1900
|
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Current variant assignment for a feature (requires `enableVariants`).
|
|
1903
|
+
* Soft-null when disabled, the feature is off/local-gated, or no variant is assigned.
|
|
1904
|
+
*/
|
|
1905
|
+
getVariant(featureKey) {
|
|
1906
|
+
if (!this.config.enableVariants) return null;
|
|
1907
|
+
const entry = this.variants?.[featureKey];
|
|
1908
|
+
const enabled = applyLocalGate(
|
|
1909
|
+
entry?.enabled === true,
|
|
1910
|
+
featureKey,
|
|
1911
|
+
this.localGates,
|
|
1912
|
+
this.localGateIndex
|
|
1913
|
+
);
|
|
1914
|
+
this.recordCheckForKey(
|
|
1915
|
+
featureKey,
|
|
1916
|
+
enabled,
|
|
1917
|
+
void 0,
|
|
1918
|
+
enabled && entry?.variant ? entry.variant : "disabled"
|
|
1919
|
+
);
|
|
1920
|
+
if (!enabled || !entry?.variant) return null;
|
|
1921
|
+
return { name: entry.variant, configurationValue: entry.configurationValue };
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Configuration payload for the assigned variant, if any.
|
|
1925
|
+
*/
|
|
1926
|
+
getVariantValue(featureKey, isT) {
|
|
1927
|
+
return decodeVariantValue(this.getVariant(featureKey)?.configurationValue, isT);
|
|
1928
|
+
}
|
|
1781
1929
|
/**
|
|
1782
1930
|
* Check if a feature is enabled
|
|
1783
1931
|
*/
|