@vercel/flags-core 1.4.0 → 1.5.0-581a9c5-20260525125622

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 CHANGED
@@ -1,5 +1,44 @@
1
1
  # @vercel/flags-core
2
2
 
3
+ ## 1.5.0-581a9c5-20260525125622
4
+
5
+ ### Minor Changes
6
+
7
+ - [#385](https://github.com/vercel/flags/pull/385) [`5b2dc37`](https://github.com/vercel/flags/commit/5b2dc37041e24811f735beede41fc8c29f508cb7) Thanks [@dferber90](https://github.com/dferber90)! - Add `bulkEvaluate` method to `FlagsClient` for resolving multiple flags against shared entities in a single call.
8
+
9
+ ```ts
10
+ const results = await client.bulkEvaluate(
11
+ [
12
+ { key: "a", defaultValue: false },
13
+ { key: "b", defaultValue: "off" },
14
+ ],
15
+ entities
16
+ );
17
+
18
+ results.a; // EvaluationResult<boolean>
19
+ results.b; // EvaluationResult<string>
20
+ ```
21
+
22
+ Avoids the per-flag overhead of separate `evaluate()` calls — the datafile is read once, entities are resolved once, and all flags share the same environment/segments lookup. Each entry in the returned record is a full `EvaluationResult` with `value`, `reason`, `outcomeType`, and `metrics`.
23
+
24
+ - [#371](https://github.com/vercel/flags/pull/371) [`bd4d01a`](https://github.com/vercel/flags/commit/bd4d01a9b2b5d70bf7ae62cda645d8cd7292ad83) Thanks [@vincent-derks](https://github.com/vincent-derks)! - Add jitter to ingest retries and the batch-flush window.
25
+
26
+ The usage tracker now uses AWS-style "Full Jitter" exponential backoff between
27
+ retry attempts (replacing the previous deterministic 100/200ms schedule) and
28
+ randomizes the 5s batch-flush window by ±20% to desynchronize concurrent
29
+ processes. When all retry attempts are exhausted the SDK now logs a structured
30
+ warning so consumers can alert on dropped batches.
31
+
32
+ ### Patch Changes
33
+
34
+ - [#382](https://github.com/vercel/flags/pull/382) [`4d90e91`](https://github.com/vercel/flags/commit/4d90e912a4d7c9d4ef986d5e8dc609c30b203242) Thanks [@dferber90](https://github.com/dferber90)! - Speed up flag evaluation on the hot path.
35
+
36
+ - `handleOutcome` no longer recomputes `scaledWeights` on every split-outcome evaluation; the per-outcome scaled weights are cached on first call.
37
+ - `matchConditions` no longer recompiles `RegExp` on every REGEX / NOT_REGEX condition; the compiled regex is cached on first call.
38
+ - `Controller.read()` and `getDatafile()` no longer re-destructure and re-spread the in-memory datafile on every call; the result is cached and rebuilt only when stream/poll replaces the underlying data.
39
+
40
+ In micro-benchmarks the pure `evaluate()` path is ~22% faster for split outcomes and ~32% faster for regex conditions. The full `client.evaluate()` path is 14–22% faster across all scenarios.
41
+
3
42
  ## 1.4.0
4
43
 
5
44
  ### Minor Changes
@@ -7,6 +7,7 @@ var __export = (target, all) => {
7
7
  // src/controller-fns.ts
8
8
  var controller_fns_exports = {};
9
9
  __export(controller_fns_exports, {
10
+ bulkEvaluate: () => bulkEvaluate2,
10
11
  controllerInstanceMap: () => controllerInstanceMap,
11
12
  evaluate: () => evaluate2,
12
13
  getDatafile: () => getDatafile,
@@ -46,6 +47,24 @@ var Packed;
46
47
 
47
48
  // src/evaluate.ts
48
49
  var MAX_REGEX_INPUT_LENGTH = 1e4;
50
+ var UINT32_MAX = 4294967295;
51
+ var SCALED_WEIGHTS = /* @__PURE__ */ Symbol("@vercel/flags-core:scaledWeights");
52
+ var COMPILED_REGEX = /* @__PURE__ */ Symbol("@vercel/flags-core:compiledRegex");
53
+ function getScaledWeights(outcome) {
54
+ const cached = outcome[SCALED_WEIGHTS];
55
+ if (cached) return cached;
56
+ const total = sum(outcome.weights);
57
+ const scaled = outcome.weights.map((w) => w / total * UINT32_MAX);
58
+ outcome[SCALED_WEIGHTS] = scaled;
59
+ return scaled;
60
+ }
61
+ function getCompiledRegex(rhs) {
62
+ const cached = rhs[COMPILED_REGEX];
63
+ if (cached) return cached;
64
+ const compiled = new RegExp(rhs.pattern, rhs.flags);
65
+ rhs[COMPILED_REGEX] = compiled;
66
+ return compiled;
67
+ }
49
68
  function exhaustivenessCheck(_) {
50
69
  throw new Error("Exhaustiveness check failed");
51
70
  }
@@ -232,12 +251,12 @@ function matchConditions(conditions, params) {
232
251
  return (isNumber(rhs) || isString(rhs)) && lhs <= rhs;
233
252
  case "regex" /* REGEX */:
234
253
  if (isString(lhs) && lhs.length <= MAX_REGEX_INPUT_LENGTH && typeof rhs === "object" && !Array.isArray(rhs) && rhs?.type === "regex") {
235
- return new RegExp(rhs.pattern, rhs.flags).test(lhs);
254
+ return getCompiledRegex(rhs).test(lhs);
236
255
  }
237
256
  return false;
238
257
  case "!regex" /* NOT_REGEX */:
239
258
  if (isString(lhs) && lhs.length <= MAX_REGEX_INPUT_LENGTH && typeof rhs === "object" && !Array.isArray(rhs) && rhs?.type === "regex") {
240
- return !new RegExp(rhs.pattern, rhs.flags).test(lhs);
259
+ return !getCompiledRegex(rhs).test(lhs);
241
260
  }
242
261
  return false;
243
262
  case "before" /* BEFORE */: {
@@ -309,13 +328,9 @@ function handleOutcome(params, outcome) {
309
328
  if (typeof lhs !== "string") {
310
329
  return { value: defaultOutcome, outcomeType: "split" /* SPLIT */ };
311
330
  }
312
- const maxValue = 4294967295;
313
331
  const value = hashInput(lhs, params.definition.seed);
314
- const sumOfWeights = sum(outcome.weights);
315
- const scaledWeights = outcome.weights.map(
316
- (weight) => weight / sumOfWeights * maxValue
317
- );
318
- const variantIndex = findWeightedIndex(scaledWeights, value, maxValue);
332
+ const scaledWeights = getScaledWeights(outcome);
333
+ const variantIndex = findWeightedIndex(scaledWeights, value, UINT32_MAX);
319
334
  return {
320
335
  value: variantIndex === -1 ? defaultOutcome : getVariant(params.definition.variants, variantIndex),
321
336
  outcomeType: "split" /* SPLIT */
@@ -371,9 +386,8 @@ function handleOutcome(params, outcome) {
371
386
  outcomeType: "rollout" /* ROLLOUT */
372
387
  };
373
388
  }
374
- const maxValue = 4294967295;
375
389
  const value = hashInput(lhs, params.definition.seed);
376
- const threshold = currentPromille / 1e5 * maxValue;
390
+ const threshold = currentPromille / 1e5 * UINT32_MAX;
377
391
  return {
378
392
  value: value < threshold ? getVariant(params.definition.variants, outcome.rollToVariant) : getVariant(
379
393
  params.definition.variants,
@@ -440,6 +454,23 @@ function evaluate(params, _visited) {
440
454
  reason: "fallthrough" /* FALLTHROUGH */
441
455
  });
442
456
  }
457
+ function bulkEvaluate(flags, shared) {
458
+ const params = {
459
+ entities: shared.entities,
460
+ environment: shared.environment,
461
+ segments: shared.segments,
462
+ definition: void 0,
463
+ defaultValue: void 0
464
+ };
465
+ const results = {};
466
+ for (const key in flags) {
467
+ const flag = flags[key];
468
+ params.definition = flag.definition;
469
+ params.defaultValue = flag.defaultValue;
470
+ results[key] = evaluate(params);
471
+ }
472
+ return results;
473
+ }
443
474
  function findWeightedIndex(weights, value, maxValue) {
444
475
  if (value < 0 || value >= maxValue) return -1;
445
476
  let sum2 = 0;
@@ -451,7 +482,7 @@ function findWeightedIndex(weights, value, maxValue) {
451
482
  }
452
483
 
453
484
  // package.json
454
- var version = "1.4.0";
485
+ var version = "1.5.0-581a9c5-20260525125622";
455
486
 
456
487
  // src/lib/report-value.ts
457
488
  function internalReportValue(key, value, data) {
@@ -557,6 +588,78 @@ async function evaluate2(id, flagKey, defaultValue, entities) {
557
588
  }
558
589
  });
559
590
  }
591
+ async function bulkEvaluate2(id, flags, entities) {
592
+ const controller = getInstance(id).controller;
593
+ let datafile;
594
+ try {
595
+ datafile = await controller.read();
596
+ } catch (error) {
597
+ const errorMessage = error instanceof Error ? error.message : "Failed to read datafile";
598
+ const results2 = {};
599
+ for (const flag of flags) {
600
+ results2[flag.key] = {
601
+ value: flag.defaultValue,
602
+ reason: "error" /* ERROR */,
603
+ errorMessage
604
+ };
605
+ }
606
+ return results2;
607
+ }
608
+ const baseMetrics = {
609
+ readMs: datafile.metrics.readMs,
610
+ source: datafile.metrics.source,
611
+ cacheStatus: datafile.metrics.cacheStatus,
612
+ connectionState: datafile.metrics.connectionState,
613
+ mode: datafile.metrics.mode
614
+ };
615
+ const projectId = datafile.projectId;
616
+ const results = {};
617
+ const toEvaluate = {};
618
+ for (const flag of flags) {
619
+ const { key, defaultValue } = flag;
620
+ const flagDefinition = datafile.definitions[key];
621
+ if (flagDefinition === void 0) {
622
+ if (projectId) {
623
+ internalReportValue(key, defaultValue, {
624
+ originProjectId: projectId,
625
+ originProvider: "vercel",
626
+ reason: "error" /* ERROR */
627
+ });
628
+ }
629
+ results[key] = {
630
+ value: defaultValue,
631
+ reason: "error" /* ERROR */,
632
+ errorCode: "FLAG_NOT_FOUND" /* FLAG_NOT_FOUND */,
633
+ errorMessage: `@vercel/flags-core: Definition not found for flag "${key}"`,
634
+ metrics: { evaluationMs: 0, ...baseMetrics }
635
+ };
636
+ continue;
637
+ }
638
+ toEvaluate[key] = { definition: flagDefinition, defaultValue };
639
+ }
640
+ const evalStartTime = Date.now();
641
+ const evaluated = bulkEvaluate(toEvaluate, {
642
+ entities: entities ?? {},
643
+ environment: datafile.environment,
644
+ segments: datafile.segments
645
+ });
646
+ const evaluationDurationMs = Date.now() - evalStartTime;
647
+ for (const key in toEvaluate) {
648
+ const result = evaluated[key];
649
+ if (projectId) {
650
+ internalReportValue(key, result.value, {
651
+ originProjectId: projectId,
652
+ originProvider: "vercel",
653
+ reason: result.reason,
654
+ outcomeType: result.reason !== "error" /* ERROR */ ? result.outcomeType : void 0
655
+ });
656
+ }
657
+ results[key] = Object.assign(result, {
658
+ metrics: { evaluationMs: evaluationDurationMs, ...baseMetrics }
659
+ });
660
+ }
661
+ return results;
662
+ }
560
663
 
561
664
  // src/create-raw-client.ts
562
665
  var idCount = 0;
@@ -623,6 +726,16 @@ function createCreateRawClient(fns) {
623
726
  }
624
727
  }
625
728
  return fns.evaluate(id, flagKey, defaultValue, entities);
729
+ },
730
+ bulkEvaluate: async (flags, entities) => {
731
+ const instance = controllerInstanceMap.get(id);
732
+ if (!instance?.initialized) {
733
+ try {
734
+ await api.initialize();
735
+ } catch {
736
+ }
737
+ }
738
+ return fns.bulkEvaluate(id, flags, entities);
626
739
  }
627
740
  };
628
741
  return api;
@@ -677,6 +790,23 @@ async function readBundledDefinitions(sdkKey) {
677
790
 
678
791
  // src/utils/usage-tracker.ts
679
792
  import { waitUntil } from "@vercel/functions";
793
+
794
+ // src/utils/backoff.ts
795
+ var DEFAULT_BASE_MS = 250;
796
+ var DEFAULT_CAP_MS = 5e3;
797
+ function getRetryDelayMs(attempt, options = {}) {
798
+ const baseMs = options.baseMs ?? DEFAULT_BASE_MS;
799
+ const capMs = options.capMs ?? DEFAULT_CAP_MS;
800
+ const ceiling = Math.min(capMs, baseMs * 2 ** Math.max(0, attempt - 1));
801
+ return Math.floor(Math.random() * ceiling);
802
+ }
803
+ function getJitteredWaitMs(baseMs, ratio) {
804
+ const min = baseMs * (1 - ratio);
805
+ const span = baseMs * 2 * ratio;
806
+ return Math.floor(min + Math.random() * span);
807
+ }
808
+
809
+ // src/utils/usage-tracker.ts
680
810
  var RESOLVED_VOID = Promise.resolve();
681
811
  var isDebugMode = process.env.DEBUG?.includes("@vercel/flags-core");
682
812
  var debugLog = (...args) => {
@@ -686,6 +816,7 @@ var debugLog = (...args) => {
686
816
  var MAX_RETRIES = 3;
687
817
  var MAX_BATCH_SIZE = 50;
688
818
  var MAX_BATCH_WAIT_MS = 5e3;
819
+ var BATCH_WAIT_JITTER_RATIO = 0.2;
689
820
  var SYMBOL_FOR_REQ_CONTEXT = /* @__PURE__ */ Symbol.for("@vercel/request-context");
690
821
  var fromSymbol = globalThis;
691
822
  function getRequestContext() {
@@ -792,7 +923,10 @@ var UsageTracker = class {
792
923
  const pending = (async () => {
793
924
  await new Promise((res) => {
794
925
  this.batcher.resolveWait = res;
795
- timeout = setTimeout(res, MAX_BATCH_WAIT_MS);
926
+ timeout = setTimeout(
927
+ res,
928
+ getJitteredWaitMs(MAX_BATCH_WAIT_MS, BATCH_WAIT_JITTER_RATIO)
929
+ );
796
930
  });
797
931
  this.batcher.pending = null;
798
932
  this.batcher.resolveWait = null;
@@ -846,7 +980,12 @@ Response body: ${await response.text().catch(() => null)}`
846
980
  error
847
981
  );
848
982
  if (attempt < MAX_RETRIES) {
849
- await new Promise((res) => setTimeout(res, attempt * 100));
983
+ const delayMs = getRetryDelayMs(attempt);
984
+ await new Promise((res) => setTimeout(res, delayMs));
985
+ } else {
986
+ console.error(
987
+ `@vercel/flags-core: Dropped ${eventsToSend.length} events after ${MAX_RETRIES} attempts (flushId=${flushId})`
988
+ );
850
989
  }
851
990
  }
852
991
  }
@@ -1384,6 +1523,11 @@ var Controller = class {
1384
1523
  state = "idle";
1385
1524
  // Data state — tagged with origin
1386
1525
  data;
1526
+ // Memoized data spread for read() / getDatafile().
1527
+ // Rebuilt only when `this.data` reference changes (e.g. on stream/poll update).
1528
+ // Holds the result of stripping `_origin`; metrics are appended per-call.
1529
+ dataViewSource = void 0;
1530
+ dataViewBase = void 0;
1387
1531
  // Sources (I/O delegates)
1388
1532
  streamSource;
1389
1533
  pollingSource;
@@ -1555,11 +1699,15 @@ var Controller = class {
1555
1699
  this.isFirstGetData = false;
1556
1700
  const [result, cacheStatus] = await this.resolveData();
1557
1701
  const readMs = Date.now() - startTime;
1558
- const { _origin, ...data } = result;
1559
- const source = originToMetricsSource(_origin);
1702
+ const source = originToMetricsSource(result._origin);
1560
1703
  this.trackRead(startTime, cacheHadDefinitions, isFirstRead, source);
1704
+ if (this.dataViewSource !== result) {
1705
+ const { _origin, ...rest } = result;
1706
+ this.dataViewBase = rest;
1707
+ this.dataViewSource = result;
1708
+ }
1561
1709
  return {
1562
- ...data,
1710
+ ...this.dataViewBase,
1563
1711
  metrics: {
1564
1712
  readMs,
1565
1713
  source,
@@ -1619,8 +1767,13 @@ var Controller = class {
1619
1767
  }
1620
1768
  }
1621
1769
  const source = originToMetricsSource(result._origin);
1770
+ if (this.dataViewSource !== result) {
1771
+ const { _origin, ...rest } = result;
1772
+ this.dataViewBase = rest;
1773
+ this.dataViewSource = result;
1774
+ }
1622
1775
  return {
1623
- ...result,
1776
+ ...this.dataViewBase,
1624
1777
  metrics: {
1625
1778
  readMs: Date.now() - startTime,
1626
1779
  source,
@@ -2045,4 +2198,4 @@ export {
2045
2198
  resetDefaultFlagsClient,
2046
2199
  createClient
2047
2200
  };
2048
- //# sourceMappingURL=chunk-OABKRAPP.js.map
2201
+ //# sourceMappingURL=chunk-IMGERCR6.js.map