@ops-ai/react-router-toggly 1.0.1 → 1.2.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/dist/server.d.cts CHANGED
@@ -62,6 +62,10 @@ type EvaluationMode = 'local' | 'remote';
62
62
  * Configuration options for Toggly
63
63
  */
64
64
  interface TogglyConfig {
65
+ /** Enable compact browser telemetry (default: true with an app key). */
66
+ enableTelemetry?: boolean;
67
+ /** Browser telemetry interval, 30000–60000 ms (default: 45000). */
68
+ telemetryFlushIntervalMs?: number;
65
69
  /** Toggly application key */
66
70
  appKey?: string;
67
71
  /** Environment name (e.g., 'Production', 'Staging') */
@@ -96,6 +100,16 @@ interface TogglyConfig {
96
100
  localGates?: LocalGate[];
97
101
  /** Optional SDK error callback for reporting fetch/evaluation failures. */
98
102
  onError?: (message: string, error?: unknown) => void;
103
+ /**
104
+ * Use `/evaluated-variants-signed` and expose {@link TogglyContextValue.getVariant} /
105
+ * `getVariantValue` (client) or the equivalent {@link TogglyServerClient} methods
106
+ * (server). Only applies in `remote` evaluationMode client-side; the server
107
+ * package forces `remote` when this is set (mirrors the Nuxt/Astro OPS-825
108
+ * override — server otherwise defaults to `definitions-signed` + local eval).
109
+ */
110
+ enableVariants?: boolean;
111
+ /** Host-minted browser capability; takes precedence over client identity targeting. */
112
+ instanceId?: string;
99
113
  /** Initial browser identity; hydrated server identity takes precedence. */
100
114
  identity?: string;
101
115
  /** User groups for targeting */
@@ -152,6 +166,8 @@ interface TogglyConfig {
152
166
  * User identity context for feature targeting
153
167
  */
154
168
  interface IdentityContext {
169
+ /** Browser capability. An empty value clears the token; omitted identity updates use client targeting. */
170
+ instanceId?: string;
155
171
  /** Unique user identifier */
156
172
  identity?: string;
157
173
  /** User groups for targeting */
@@ -171,6 +187,13 @@ interface IdentityContext {
171
187
  * Feature flags state (boolean or entity gate per flag)
172
188
  */
173
189
  type FeatureFlags = EvaluatedDefinitions;
190
+ /**
191
+ * Assigned variant for a feature (aligned with @ops-ai/feature-flags-toggly).
192
+ */
193
+ interface VariantResult {
194
+ name: string;
195
+ configurationValue?: unknown;
196
+ }
174
197
  /**
175
198
  * Feature evaluation result
176
199
  */
@@ -347,12 +370,19 @@ declare class TogglyServerClient {
347
370
  private flags;
348
371
  /** Raw definitions-signed models keyed by featureKey. */
349
372
  private definitions;
373
+ /** Evaluated-variants-signed defs when `enableVariants`; otherwise null. */
374
+ private variants;
350
375
  private hooks;
351
376
  private initialized;
352
377
  /** Dedupes concurrent cold-start definition fetches. */
353
378
  private definitionsLoadPromise;
354
379
  /** Single-flight for definition GET so concurrent joiners are not double-counted. */
355
380
  private definitionsFetchInFlight;
381
+ /**
382
+ * Serializes variants-mode init so concurrent identities cannot interleave
383
+ * shared `this.variants` / `this.flags` mutations.
384
+ */
385
+ private variantsInitChain;
356
386
  private ws;
357
387
  private wsConnected;
358
388
  private wsReconnectTimer;
@@ -412,24 +442,46 @@ declare class TogglyServerClient {
412
442
  */
413
443
  get isWsConnected(): boolean;
414
444
  /**
415
- * Initialize the client by fetching feature definitions.
416
- * When already initialized, rebinds identity (including clearing it) and
417
- * re-snapshots flags without re-fetching (definitions are identity-agnostic).
418
- * Concurrent cold starts share one fetch; each caller still receives a
419
- * request-local snapshot for its identity.
445
+ * Initialize the client and optionally bind identity.
446
+ * Local mode: concurrent cold starts share one definitions fetch; each caller
447
+ * still receives a request-local snapshot for its identity.
448
+ * Variants mode: inits are serialized so concurrent identities cannot
449
+ * interleave remote variant assignments on the shared client.
420
450
  */
421
451
  init(identity?: string): Promise<FeatureFlags>;
452
+ /**
453
+ * Variants are remote-evaluated; identity must be bound before fetch so
454
+ * `/evaluated-variants-signed` receives the correct `userId`.
455
+ */
456
+ private initWithVariants;
457
+ private initWithLocalDefinitions;
422
458
  /**
423
459
  * Fetch feature definitions from the API (definitions-signed; no identity query).
424
460
  * Counts one definition-refresh hit/miss per performed attempt; concurrent
425
461
  * in-flight joiners await the same fetch and are not counted.
426
462
  */
427
463
  fetchFlags(identity?: string): Promise<FeatureFlags>;
464
+ private applyNoAppKeyDefaults;
465
+ private applyParsedDefinitionsBody;
466
+ private hasLastKnownGoodDefinitions;
467
+ private buildDefinitionsFetchUrl;
468
+ private noteNotModifiedResponse;
469
+ private noteAppliedDefinitionsRevision;
470
+ private recoverDefinitionsFetchError;
428
471
  private performDefinitionsFetch;
429
472
  /**
430
473
  * Get all flags
431
474
  */
432
475
  getFlags(): FeatureFlags;
476
+ /**
477
+ * Current variant assignment for a feature (requires `enableVariants`).
478
+ * Soft-null when disabled, the feature is off/local-gated, or no variant is assigned.
479
+ */
480
+ getVariant(featureKey: string): VariantResult | null;
481
+ /**
482
+ * Configuration payload for the assigned variant, if any.
483
+ */
484
+ getVariantValue<T = unknown>(featureKey: string, isT?: (v: unknown) => v is T): T | null;
433
485
  /**
434
486
  * Check if a feature is enabled
435
487
  */
package/dist/server.d.ts CHANGED
@@ -62,6 +62,10 @@ type EvaluationMode = 'local' | 'remote';
62
62
  * Configuration options for Toggly
63
63
  */
64
64
  interface TogglyConfig {
65
+ /** Enable compact browser telemetry (default: true with an app key). */
66
+ enableTelemetry?: boolean;
67
+ /** Browser telemetry interval, 30000–60000 ms (default: 45000). */
68
+ telemetryFlushIntervalMs?: number;
65
69
  /** Toggly application key */
66
70
  appKey?: string;
67
71
  /** Environment name (e.g., 'Production', 'Staging') */
@@ -96,6 +100,16 @@ interface TogglyConfig {
96
100
  localGates?: LocalGate[];
97
101
  /** Optional SDK error callback for reporting fetch/evaluation failures. */
98
102
  onError?: (message: string, error?: unknown) => void;
103
+ /**
104
+ * Use `/evaluated-variants-signed` and expose {@link TogglyContextValue.getVariant} /
105
+ * `getVariantValue` (client) or the equivalent {@link TogglyServerClient} methods
106
+ * (server). Only applies in `remote` evaluationMode client-side; the server
107
+ * package forces `remote` when this is set (mirrors the Nuxt/Astro OPS-825
108
+ * override — server otherwise defaults to `definitions-signed` + local eval).
109
+ */
110
+ enableVariants?: boolean;
111
+ /** Host-minted browser capability; takes precedence over client identity targeting. */
112
+ instanceId?: string;
99
113
  /** Initial browser identity; hydrated server identity takes precedence. */
100
114
  identity?: string;
101
115
  /** User groups for targeting */
@@ -152,6 +166,8 @@ interface TogglyConfig {
152
166
  * User identity context for feature targeting
153
167
  */
154
168
  interface IdentityContext {
169
+ /** Browser capability. An empty value clears the token; omitted identity updates use client targeting. */
170
+ instanceId?: string;
155
171
  /** Unique user identifier */
156
172
  identity?: string;
157
173
  /** User groups for targeting */
@@ -171,6 +187,13 @@ interface IdentityContext {
171
187
  * Feature flags state (boolean or entity gate per flag)
172
188
  */
173
189
  type FeatureFlags = EvaluatedDefinitions;
190
+ /**
191
+ * Assigned variant for a feature (aligned with @ops-ai/feature-flags-toggly).
192
+ */
193
+ interface VariantResult {
194
+ name: string;
195
+ configurationValue?: unknown;
196
+ }
174
197
  /**
175
198
  * Feature evaluation result
176
199
  */
@@ -347,12 +370,19 @@ declare class TogglyServerClient {
347
370
  private flags;
348
371
  /** Raw definitions-signed models keyed by featureKey. */
349
372
  private definitions;
373
+ /** Evaluated-variants-signed defs when `enableVariants`; otherwise null. */
374
+ private variants;
350
375
  private hooks;
351
376
  private initialized;
352
377
  /** Dedupes concurrent cold-start definition fetches. */
353
378
  private definitionsLoadPromise;
354
379
  /** Single-flight for definition GET so concurrent joiners are not double-counted. */
355
380
  private definitionsFetchInFlight;
381
+ /**
382
+ * Serializes variants-mode init so concurrent identities cannot interleave
383
+ * shared `this.variants` / `this.flags` mutations.
384
+ */
385
+ private variantsInitChain;
356
386
  private ws;
357
387
  private wsConnected;
358
388
  private wsReconnectTimer;
@@ -412,24 +442,46 @@ declare class TogglyServerClient {
412
442
  */
413
443
  get isWsConnected(): boolean;
414
444
  /**
415
- * Initialize the client by fetching feature definitions.
416
- * When already initialized, rebinds identity (including clearing it) and
417
- * re-snapshots flags without re-fetching (definitions are identity-agnostic).
418
- * Concurrent cold starts share one fetch; each caller still receives a
419
- * request-local snapshot for its identity.
445
+ * Initialize the client and optionally bind identity.
446
+ * Local mode: concurrent cold starts share one definitions fetch; each caller
447
+ * still receives a request-local snapshot for its identity.
448
+ * Variants mode: inits are serialized so concurrent identities cannot
449
+ * interleave remote variant assignments on the shared client.
420
450
  */
421
451
  init(identity?: string): Promise<FeatureFlags>;
452
+ /**
453
+ * Variants are remote-evaluated; identity must be bound before fetch so
454
+ * `/evaluated-variants-signed` receives the correct `userId`.
455
+ */
456
+ private initWithVariants;
457
+ private initWithLocalDefinitions;
422
458
  /**
423
459
  * Fetch feature definitions from the API (definitions-signed; no identity query).
424
460
  * Counts one definition-refresh hit/miss per performed attempt; concurrent
425
461
  * in-flight joiners await the same fetch and are not counted.
426
462
  */
427
463
  fetchFlags(identity?: string): Promise<FeatureFlags>;
464
+ private applyNoAppKeyDefaults;
465
+ private applyParsedDefinitionsBody;
466
+ private hasLastKnownGoodDefinitions;
467
+ private buildDefinitionsFetchUrl;
468
+ private noteNotModifiedResponse;
469
+ private noteAppliedDefinitionsRevision;
470
+ private recoverDefinitionsFetchError;
428
471
  private performDefinitionsFetch;
429
472
  /**
430
473
  * Get all flags
431
474
  */
432
475
  getFlags(): FeatureFlags;
476
+ /**
477
+ * Current variant assignment for a feature (requires `enableVariants`).
478
+ * Soft-null when disabled, the feature is off/local-gated, or no variant is assigned.
479
+ */
480
+ getVariant(featureKey: string): VariantResult | null;
481
+ /**
482
+ * Configuration payload for the assigned variant, if any.
483
+ */
484
+ getVariantValue<T = unknown>(featureKey: string, isT?: (v: unknown) => v is T): T | null;
433
485
  /**
434
486
  * Check if a feature is enabled
435
487
  */
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 pathSegment = mode === "local" ? "definitions-signed" : "evaluated-signed";
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;
@@ -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.0.1";
582
+ var SDK_VERSION = "1.2.0";
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.0.1";
1102
+ var SDK_VERSION2 = "1.2.0";
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
- const evaluated = isFeatureEnabledLocal(
1569
- this.definitions,
1570
- featureKey,
1571
- this.buildEvalContext(identityOverride, entityContext),
1572
- this.config.featureDefaults?.[featureKey] ?? defaultValue
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 by fetching feature definitions.
1651
- * When already initialized, rebinds identity (including clearing it) and
1652
- * re-snapshots flags without re-fetching (definitions are identity-agnostic).
1653
- * Concurrent cold starts share one fetch; each caller still receives a
1654
- * request-local snapshot for its identity.
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
- async performDefinitionsFetch() {
1701
- if (!this.config.appKey) {
1702
- this.logger.debug("No appKey, using featureDefaults.");
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
- return this.flags;
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 = appendDefinitionsRevisionParam(
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 headers = buildDefinitionFetchHeaders(
1718
- previousRevision ? { "If-None-Match": previousRevision } : {}
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.logger.debug("Definitions unchanged (304)");
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.definitions = parseDefinitionsPayload(parsed);
1747
- this.flags = {
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.logger.warn("Failed to fetch flags, preserving last-known-good flags when available.", error);
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
  */