@vercel/flags-core 1.1.0-cea788b-20260305194413 → 1.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/README.md +2 -0
  3. package/dist/{chunk-EB6T2UJ6.js → chunk-J33BA25D.js} +958 -527
  4. package/dist/chunk-J33BA25D.js.map +1 -0
  5. package/dist/{chunk-326YN2YT.cjs → chunk-LBP3OTYA.cjs} +965 -535
  6. package/dist/chunk-LBP3OTYA.cjs.map +1 -0
  7. package/dist/{chunk-IBIJOSJL.js → chunk-UYFYFLI7.js} +953 -523
  8. package/dist/chunk-UYFYFLI7.js.map +1 -0
  9. package/dist/{chunk-WN6UYCJZ.cjs → chunk-ZLGLABMG.cjs} +969 -538
  10. package/dist/chunk-ZLGLABMG.cjs.map +1 -0
  11. package/dist/index.default.cjs +3 -2
  12. package/dist/index.default.cjs.map +1 -1
  13. package/dist/index.default.d.cts +78 -83
  14. package/dist/index.default.d.ts +78 -83
  15. package/dist/index.default.js +4 -3
  16. package/dist/index.next-js.cjs +3 -2
  17. package/dist/index.next-js.cjs.map +1 -1
  18. package/dist/index.next-js.d.cts +78 -83
  19. package/dist/index.next-js.d.ts +78 -83
  20. package/dist/index.next-js.js +4 -3
  21. package/dist/openfeature.default.cjs +2 -2
  22. package/dist/openfeature.default.d.cts +1 -1
  23. package/dist/openfeature.default.d.ts +1 -1
  24. package/dist/openfeature.default.js +1 -1
  25. package/dist/openfeature.next-js.cjs +2 -2
  26. package/dist/openfeature.next-js.d.cts +1 -1
  27. package/dist/openfeature.next-js.d.ts +1 -1
  28. package/dist/openfeature.next-js.js +1 -1
  29. package/dist/{types-CpRz8o4d.d.cts → types-DtnecWgl.d.cts} +8 -4
  30. package/dist/{types-CpRz8o4d.d.ts → types-DtnecWgl.d.ts} +8 -4
  31. package/package.json +3 -4
  32. package/dist/chunk-326YN2YT.cjs.map +0 -1
  33. package/dist/chunk-EB6T2UJ6.js.map +0 -1
  34. package/dist/chunk-IBIJOSJL.js.map +0 -1
  35. package/dist/chunk-WN6UYCJZ.cjs.map +0 -1
@@ -4,9 +4,10 @@ var __export = (target, all) => {
4
4
  __defProp(target, name, { get: all[name], enumerable: true });
5
5
  };
6
6
 
7
- // src/client-fns.ts
8
- var client_fns_exports = {};
9
- __export(client_fns_exports, {
7
+ // src/controller-fns.ts
8
+ var controller_fns_exports = {};
9
+ __export(controller_fns_exports, {
10
+ controllerInstanceMap: () => controllerInstanceMap,
10
11
  evaluate: () => evaluate2,
11
12
  getDatafile: () => getDatafile,
12
13
  getFallbackDatafile: () => getFallbackDatafile,
@@ -14,9 +15,6 @@ __export(client_fns_exports, {
14
15
  shutdown: () => shutdown
15
16
  });
16
17
 
17
- // src/client-map.ts
18
- var clientMap = /* @__PURE__ */ new Map();
19
-
20
18
  // src/evaluate.ts
21
19
  import { xxHash32 as hashInput } from "js-xxhash";
22
20
 
@@ -46,12 +44,11 @@ var Packed;
46
44
  })(AccessorType = Packed2.AccessorType || (Packed2.AccessorType = {}));
47
45
  })(Packed || (Packed = {}));
48
46
 
49
- // src/utils.ts
47
+ // src/evaluate.ts
48
+ var MAX_REGEX_INPUT_LENGTH = 1e4;
50
49
  function exhaustivenessCheck(_) {
51
50
  throw new Error("Exhaustiveness check failed");
52
51
  }
53
-
54
- // src/evaluate.ts
55
52
  function getProperty(obj, pathArray) {
56
53
  return pathArray.reduce((acc, key) => {
57
54
  if (acc && key in acc) {
@@ -96,10 +93,12 @@ var IGNORE_CASE_COMPARATORS = /* @__PURE__ */ new Set([
96
93
  "!contains" /* NOT_CONTAINS */
97
94
  ]);
98
95
  function matchTargetList(targets, params) {
99
- for (const [kind, attributes] of Object.entries(targets)) {
100
- for (const [attribute, values] of Object.entries(attributes)) {
96
+ for (const kind in targets) {
97
+ const attributes = targets[kind];
98
+ for (const attribute in attributes) {
101
99
  const entity = access([kind, attribute], params);
102
- if (isString(entity) && values.includes(entity)) return true;
100
+ if (isString(entity) && attributes[attribute].includes(entity))
101
+ return true;
103
102
  }
104
103
  }
105
104
  return false;
@@ -232,12 +231,12 @@ function matchConditions(conditions, params) {
232
231
  if (lhs === null || lhs === void 0) return false;
233
232
  return (isNumber(rhs) || isString(rhs)) && lhs <= rhs;
234
233
  case "regex" /* REGEX */:
235
- if (isString(lhs) && typeof rhs === "object" && !Array.isArray(rhs) && rhs?.type === "regex") {
234
+ if (isString(lhs) && lhs.length <= MAX_REGEX_INPUT_LENGTH && typeof rhs === "object" && !Array.isArray(rhs) && rhs?.type === "regex") {
236
235
  return new RegExp(rhs.pattern, rhs.flags).test(lhs);
237
236
  }
238
237
  return false;
239
238
  case "!regex" /* NOT_REGEX */:
240
- if (isString(lhs) && typeof rhs === "object" && !Array.isArray(rhs) && rhs?.type === "regex") {
239
+ if (isString(lhs) && lhs.length <= MAX_REGEX_INPUT_LENGTH && typeof rhs === "object" && !Array.isArray(rhs) && rhs?.type === "regex") {
241
240
  return !new RegExp(rhs.pattern, rhs.flags).test(lhs);
242
241
  }
243
242
  return false;
@@ -328,7 +327,7 @@ function handleOutcome(params, outcome) {
328
327
  }
329
328
  }
330
329
  }
331
- function evaluate(params) {
330
+ function evaluate(params, _visited) {
332
331
  const envConfig = params.definition.environments[params.environment];
333
332
  if (typeof envConfig === "number") {
334
333
  return Object.assign(handleOutcome(params, envConfig), {
@@ -349,7 +348,16 @@ function evaluate(params) {
349
348
  `Could not find envConfig for "${envConfig.reuse}" when reusing`
350
349
  );
351
350
  }
352
- return evaluate({ ...params, environment: envConfig.reuse });
351
+ const visited = _visited ?? /* @__PURE__ */ new Set();
352
+ if (visited.has(envConfig.reuse)) {
353
+ return {
354
+ reason: "error" /* ERROR */,
355
+ errorMessage: `Circular environment reuse detected: "${envConfig.reuse}"`,
356
+ value: params.defaultValue
357
+ };
358
+ }
359
+ visited.add(params.environment);
360
+ return evaluate({ ...params, environment: envConfig.reuse }, visited);
353
361
  }
354
362
  if (envConfig.targets) {
355
363
  const matchedIndex = envConfig.targets.findIndex(
@@ -382,7 +390,7 @@ function findWeightedIndex(weights, value, maxValue) {
382
390
  }
383
391
 
384
392
  // package.json
385
- var version = "1.1.0-cea788b-20260305194413";
393
+ var version = "1.1.0";
386
394
 
387
395
  // src/lib/report-value.ts
388
396
  function internalReportValue(key, value, data) {
@@ -394,37 +402,67 @@ function internalReportValue(key, value, data) {
394
402
  });
395
403
  }
396
404
 
397
- // src/client-fns.ts
405
+ // src/controller-fns.ts
406
+ var controllerInstanceMap = /* @__PURE__ */ new Map();
407
+ function getInstance(id) {
408
+ const instance = controllerInstanceMap.get(id);
409
+ if (!instance) {
410
+ throw new Error(
411
+ `@vercel/flags-core: Client instance ${id} not found. It may have been shut down.`
412
+ );
413
+ }
414
+ return instance;
415
+ }
398
416
  function initialize(id) {
399
- return clientMap.get(id).dataSource.initialize();
417
+ return getInstance(id).controller.initialize();
400
418
  }
401
419
  function shutdown(id) {
402
- return clientMap.get(id).dataSource.shutdown();
420
+ return getInstance(id).controller.shutdown();
403
421
  }
404
422
  function getDatafile(id) {
405
- return clientMap.get(id).dataSource.getDatafile();
423
+ return getInstance(id).controller.getDatafile();
406
424
  }
407
425
  function getFallbackDatafile(id) {
408
- const ds = clientMap.get(id).dataSource;
426
+ const ds = getInstance(id).controller;
409
427
  if (ds.getFallbackDatafile) return ds.getFallbackDatafile();
410
428
  throw new Error("flags: This data source does not support fallbacks");
411
429
  }
412
430
  async function evaluate2(id, flagKey, defaultValue, entities) {
413
- const ds = clientMap.get(id).dataSource;
414
- const datafile = await ds.read();
431
+ const controller = getInstance(id).controller;
432
+ let datafile;
433
+ try {
434
+ datafile = await controller.read();
435
+ } catch (error) {
436
+ if (defaultValue !== void 0) {
437
+ return {
438
+ value: defaultValue,
439
+ reason: "error" /* ERROR */,
440
+ errorMessage: error instanceof Error ? error.message : "Failed to read datafile"
441
+ };
442
+ }
443
+ throw error;
444
+ }
415
445
  const flagDefinition = datafile.definitions[flagKey];
416
446
  if (flagDefinition === void 0) {
447
+ if (datafile.projectId) {
448
+ internalReportValue(flagKey, defaultValue, {
449
+ originProjectId: datafile.projectId,
450
+ originProvider: "vercel",
451
+ reason: "error" /* ERROR */
452
+ });
453
+ }
417
454
  return {
418
455
  value: defaultValue,
419
456
  reason: "error" /* ERROR */,
420
457
  errorCode: "FLAG_NOT_FOUND" /* FLAG_NOT_FOUND */,
421
- errorMessage: `Definition not found for flag "${flagKey}"`,
458
+ errorMessage: `@vercel/flags-core: Definition not found for flag "${flagKey}"`,
422
459
  metrics: {
423
460
  evaluationMs: 0,
424
461
  readMs: datafile.metrics.readMs,
425
462
  source: datafile.metrics.source,
426
463
  cacheStatus: datafile.metrics.cacheStatus,
427
- connectionState: datafile.metrics.connectionState
464
+ connectionState: datafile.metrics.connectionState,
465
+ mode: datafile.metrics.mode
428
466
  }
429
467
  };
430
468
  }
@@ -451,7 +489,8 @@ async function evaluate2(id, flagKey, defaultValue, entities) {
451
489
  readMs: datafile.metrics.readMs,
452
490
  source: datafile.metrics.source,
453
491
  cacheStatus: datafile.metrics.cacheStatus,
454
- connectionState: datafile.metrics.connectionState
492
+ connectionState: datafile.metrics.connectionState,
493
+ mode: datafile.metrics.mode
455
494
  }
456
495
  });
457
496
  }
@@ -469,18 +508,22 @@ async function performInitialize(instance, initFn) {
469
508
  }
470
509
  function createCreateRawClient(fns) {
471
510
  return function createRawClient({
472
- dataSource,
511
+ controller,
473
512
  origin
474
513
  }) {
475
514
  const id = idCount++;
476
- clientMap.set(id, { dataSource, initialized: false, initPromise: null });
515
+ controllerInstanceMap.set(id, {
516
+ controller,
517
+ initialized: false,
518
+ initPromise: null
519
+ });
477
520
  const api = {
478
521
  origin,
479
522
  initialize: async () => {
480
- let instance = clientMap.get(id);
523
+ let instance = controllerInstanceMap.get(id);
481
524
  if (!instance) {
482
- instance = { dataSource, initialized: false, initPromise: null };
483
- clientMap.set(id, instance);
525
+ instance = { controller, initialized: false, initPromise: null };
526
+ controllerInstanceMap.set(id, instance);
484
527
  }
485
528
  if (instance.initialized) return;
486
529
  if (!instance.initPromise) {
@@ -493,15 +536,29 @@ function createCreateRawClient(fns) {
493
536
  },
494
537
  shutdown: async () => {
495
538
  await fns.shutdown(id);
496
- clientMap.delete(id);
539
+ controllerInstanceMap.delete(id);
540
+ },
541
+ getDatafile: async () => {
542
+ const instance = controllerInstanceMap.get(id);
543
+ if (instance?.initPromise) {
544
+ try {
545
+ await instance.initPromise;
546
+ } catch {
547
+ }
548
+ }
549
+ return fns.getDatafile(id);
497
550
  },
498
- getDatafile: () => fns.getDatafile(id),
499
551
  getFallbackDatafile: () => {
500
552
  return fns.getFallbackDatafile(id);
501
553
  },
502
554
  evaluate: async (flagKey, defaultValue, entities) => {
503
- const instance = clientMap.get(id);
504
- if (!instance?.initialized) await api.initialize();
555
+ const instance = controllerInstanceMap.get(id);
556
+ if (!instance?.initialized) {
557
+ try {
558
+ await api.initialize();
559
+ } catch {
560
+ }
561
+ }
505
562
  return fns.evaluate(id, flagKey, defaultValue, entities);
506
563
  }
507
564
  };
@@ -509,20 +566,6 @@ function createCreateRawClient(fns) {
509
566
  };
510
567
  }
511
568
 
512
- // src/errors.ts
513
- var FallbackNotFoundError = class extends Error {
514
- constructor() {
515
- super("@vercel/flags-core: Bundled definitions file not found.");
516
- this.name = "FallbackNotFoundError";
517
- }
518
- };
519
- var FallbackEntryNotFoundError = class extends Error {
520
- constructor() {
521
- super("@vercel/flags-core: No bundled definitions found for SDK key.");
522
- this.name = "FallbackEntryNotFoundError";
523
- }
524
- };
525
-
526
569
  // src/utils/read-bundled-definitions.ts
527
570
  var sdkKeyHashCache = /* @__PURE__ */ new Map();
528
571
  async function computeHash(sdkKey) {
@@ -567,11 +610,6 @@ async function readBundledDefinitions(sdkKey) {
567
610
  return { definitions: null, state: "missing-entry" };
568
611
  }
569
612
 
570
- // src/utils/sleep.ts
571
- function sleep(ms) {
572
- return new Promise((resolve) => setTimeout(resolve, ms));
573
- }
574
-
575
613
  // src/utils/usage-tracker.ts
576
614
  import { waitUntil } from "@vercel/functions";
577
615
  var RESOLVED_VOID = Promise.resolve();
@@ -580,9 +618,9 @@ var debugLog = (...args) => {
580
618
  if (!isDebugMode) return;
581
619
  console.log(...args);
582
620
  };
621
+ var MAX_RETRIES = 3;
583
622
  var MAX_BATCH_SIZE = 50;
584
623
  var MAX_BATCH_WAIT_MS = 5e3;
585
- var trackedRequests = /* @__PURE__ */ new WeakSet();
586
624
  var SYMBOL_FOR_REQ_CONTEXT = /* @__PURE__ */ Symbol.for("@vercel/request-context");
587
625
  var fromSymbol = globalThis;
588
626
  function getRequestContext() {
@@ -600,24 +638,30 @@ function getRequestContext() {
600
638
  }
601
639
  }
602
640
  var UsageTracker = class {
603
- sdkKey;
604
- host;
641
+ flushCounter = 0;
642
+ options;
643
+ trackedRequests = /* @__PURE__ */ new WeakSet();
605
644
  batcher = {
606
645
  events: [],
607
646
  resolveWait: null,
608
647
  pending: null
609
648
  };
610
649
  constructor(options) {
611
- this.sdkKey = options.sdkKey;
612
- this.host = options.host;
650
+ this.options = options;
613
651
  }
614
652
  /**
615
653
  * Triggers an immediate flush of any pending events.
616
654
  * Returns a promise that resolves when the flush completes.
617
655
  */
618
656
  flush() {
619
- this.batcher.resolveWait?.();
620
- return this.batcher.pending ?? RESOLVED_VOID;
657
+ if (this.batcher.pending) {
658
+ this.batcher.resolveWait?.();
659
+ return this.batcher.pending;
660
+ }
661
+ if (this.batcher.events.length > 0) {
662
+ return this.flushEvents();
663
+ }
664
+ return RESOLVED_VOID;
621
665
  }
622
666
  /**
623
667
  * Tracks a config read event. Deduplicates by request context.
@@ -626,8 +670,8 @@ var UsageTracker = class {
626
670
  try {
627
671
  const { ctx, headers } = getRequestContext();
628
672
  if (ctx) {
629
- if (trackedRequests.has(ctx)) return;
630
- trackedRequests.add(ctx);
673
+ if (this.trackedRequests.has(ctx)) return;
674
+ this.trackedRequests.add(ctx);
631
675
  }
632
676
  const event = {
633
677
  type: "FLAGS_CONFIG_READ",
@@ -646,6 +690,9 @@ var UsageTracker = class {
646
690
  if (options.cacheStatus !== void 0) {
647
691
  event.payload.cacheStatus = options.cacheStatus;
648
692
  }
693
+ if (options.cacheAction !== void 0) {
694
+ event.payload.cacheAction = options.cacheAction;
695
+ }
649
696
  if (options.cacheIsFirstRead !== void 0) {
650
697
  event.payload.cacheIsFirstRead = options.cacheIsFirstRead;
651
698
  }
@@ -658,6 +705,16 @@ var UsageTracker = class {
658
705
  if (options.configUpdatedAt !== void 0) {
659
706
  event.payload.configUpdatedAt = options.configUpdatedAt;
660
707
  }
708
+ if (options.mode !== void 0) {
709
+ event.payload.mode = options.mode;
710
+ }
711
+ if (options.revision !== void 0) {
712
+ event.payload.revision = String(options.revision);
713
+ }
714
+ }
715
+ const environment = process.env.VERCEL_ENV || process.env.NODE_ENV || void 0;
716
+ if (environment) {
717
+ event.payload.environment = environment;
661
718
  }
662
719
  this.batcher.events.push(event);
663
720
  this.scheduleFlush();
@@ -678,7 +735,10 @@ var UsageTracker = class {
678
735
  if (timeout) clearTimeout(timeout);
679
736
  await this.flushEvents();
680
737
  })();
681
- waitUntil(pending);
738
+ try {
739
+ waitUntil(pending);
740
+ } catch {
741
+ }
682
742
  this.batcher.pending = pending;
683
743
  }
684
744
  if (this.batcher.events.length >= MAX_BATCH_SIZE) {
@@ -689,41 +749,301 @@ var UsageTracker = class {
689
749
  if (this.batcher.events.length === 0) return;
690
750
  const eventsToSend = this.batcher.events;
691
751
  this.batcher.events = [];
692
- try {
693
- const response = await fetch(`${this.host}/v1/ingest`, {
694
- method: "POST",
695
- headers: {
696
- "Content-Type": "application/json",
697
- Authorization: `Bearer ${this.sdkKey}`,
698
- "User-Agent": `VercelFlagsCore/${version}`,
699
- ...isDebugMode ? { "x-vercel-debug-ingest": "1" } : null
700
- },
701
- body: JSON.stringify(eventsToSend)
702
- });
703
- debugLog(
704
- `@vercel/flags-core: Ingest response ${response.status} for ${eventsToSend.length} events on ${response.headers.get("x-vercel-id")}`
705
- );
706
- if (!response.ok) {
752
+ const flushId = ++this.flushCounter;
753
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
754
+ try {
755
+ const response = await this.options.fetch(
756
+ `${this.options.host}/v1/ingest`,
757
+ {
758
+ method: "POST",
759
+ headers: {
760
+ "Content-Type": "application/json",
761
+ Authorization: `Bearer ${this.options.sdkKey}`,
762
+ "User-Agent": `VercelFlagsCore/${version}`,
763
+ ...isDebugMode ? { "x-vercel-debug-ingest": "1" } : null
764
+ },
765
+ body: JSON.stringify(eventsToSend)
766
+ }
767
+ );
707
768
  debugLog(
708
- "@vercel/flags-core: Failed to send events:",
709
- response.statusText
769
+ `@vercel/flags-core: Ingest response ${response.status} for ${eventsToSend.length} events on ${response.headers.get("x-vercel-id")}`
770
+ );
771
+ if (response.ok) {
772
+ break;
773
+ }
774
+ throw new Error(
775
+ `Ingest endpoint responded with status ${response.status} for ${eventsToSend.length} events on request ${response.headers.get("x-vercel-id")}.
776
+ Response body: ${await response.text().catch(() => null)}`
777
+ );
778
+ } catch (error) {
779
+ console.error(
780
+ `@vercel/flags-core: Error sending events (attempt=${attempt}/${MAX_RETRIES} flushId=${flushId}):`,
781
+ error
710
782
  );
783
+ if (attempt < MAX_RETRIES) {
784
+ await new Promise((res) => setTimeout(res, attempt * 100));
785
+ }
711
786
  }
787
+ }
788
+ }
789
+ };
790
+
791
+ // src/errors.ts
792
+ var FallbackNotFoundError = class extends Error {
793
+ constructor() {
794
+ super("@vercel/flags-core: Bundled definitions file not found.");
795
+ this.name = "FallbackNotFoundError";
796
+ }
797
+ };
798
+ var FallbackEntryNotFoundError = class extends Error {
799
+ constructor() {
800
+ super("@vercel/flags-core: No bundled definitions found for SDK key.");
801
+ this.name = "FallbackEntryNotFoundError";
802
+ }
803
+ };
804
+
805
+ // src/controller/bundled-source.ts
806
+ var BundledSource = class {
807
+ promise;
808
+ options;
809
+ constructor(options) {
810
+ this.options = options;
811
+ }
812
+ /**
813
+ * Load bundled definitions.
814
+ * Throws if bundled definitions are not available.
815
+ */
816
+ async load() {
817
+ const result = await this.getResult();
818
+ if (result.state === "ok" && result.definitions) {
819
+ return result.definitions;
820
+ }
821
+ throw new Error(
822
+ "@vercel/flags-core: No flag definitions available. Bundled definitions not found."
823
+ );
824
+ }
825
+ /**
826
+ * Get the raw BundledDefinitions (for getFallbackDatafile).
827
+ * Throws typed errors if not available.
828
+ */
829
+ async getRaw() {
830
+ const result = await this.getResult();
831
+ switch (result.state) {
832
+ case "ok":
833
+ return result.definitions;
834
+ case "missing-file":
835
+ throw new FallbackNotFoundError();
836
+ case "missing-entry":
837
+ throw new FallbackEntryNotFoundError();
838
+ case "unexpected-error":
839
+ throw new Error(
840
+ "@vercel/flags-core: Failed to read bundled definitions: " + String(result.error)
841
+ );
842
+ }
843
+ }
844
+ /**
845
+ * Check if bundled definitions loaded successfully (without throwing).
846
+ */
847
+ async tryLoad() {
848
+ const result = await this.getResult();
849
+ if (result?.state === "ok" && result.definitions) {
850
+ return result.definitions;
851
+ }
852
+ return void 0;
853
+ }
854
+ getResult() {
855
+ if (!this.promise) {
856
+ this.promise = this.options.readBundledDefinitions(this.options.sdkKey);
857
+ }
858
+ return this.promise;
859
+ }
860
+ };
861
+
862
+ // src/controller/fetch-datafile.ts
863
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
864
+ async function fetchDatafile(options) {
865
+ const controller = new AbortController();
866
+ const timeoutId = setTimeout(
867
+ () => controller.abort(),
868
+ DEFAULT_FETCH_TIMEOUT_MS
869
+ );
870
+ const onExternalAbort = () => controller.abort();
871
+ if (options.signal) {
872
+ if (options.signal.aborted) {
873
+ clearTimeout(timeoutId);
874
+ throw new Error("Fetch aborted");
875
+ }
876
+ options.signal.addEventListener("abort", onExternalAbort, { once: true });
877
+ }
878
+ try {
879
+ const res = await options.fetch(`${options.host}/v1/datafile`, {
880
+ headers: {
881
+ Authorization: `Bearer ${options.sdkKey}`,
882
+ "User-Agent": `VercelFlagsCore/${version}`
883
+ },
884
+ signal: controller.signal
885
+ });
886
+ clearTimeout(timeoutId);
887
+ options.signal?.removeEventListener("abort", onExternalAbort);
888
+ if (!res.ok) {
889
+ throw new Error(`Failed to fetch data: ${res.statusText}`);
890
+ }
891
+ return res.json();
892
+ } catch (error) {
893
+ clearTimeout(timeoutId);
894
+ options.signal?.removeEventListener("abort", onExternalAbort);
895
+ throw error instanceof Error ? error : new Error("Unknown fetch error");
896
+ }
897
+ }
898
+
899
+ // src/controller/normalized-options.ts
900
+ var DEFAULT_STREAM_INIT_TIMEOUT_MS = 3e3;
901
+ var DEFAULT_POLLING_INTERVAL_MS = 3e4;
902
+ var MIN_POLLING_INTERVAL_MS = 3e4;
903
+ var DEFAULT_POLLING_INIT_TIMEOUT_MS = 3e3;
904
+ function normalizeOptions(options) {
905
+ const autoDetectedBuildStep = process.env.CI === "1" || process.env.NEXT_PHASE === "phase-production-build";
906
+ const buildStep = options.buildStep ?? autoDetectedBuildStep;
907
+ let stream;
908
+ if (options.stream === void 0 || options.stream === true) {
909
+ stream = { enabled: true, initTimeoutMs: DEFAULT_STREAM_INIT_TIMEOUT_MS };
910
+ } else if (options.stream === false) {
911
+ stream = { enabled: false, initTimeoutMs: 0 };
912
+ } else {
913
+ stream = { enabled: true, initTimeoutMs: options.stream.initTimeoutMs };
914
+ }
915
+ let polling;
916
+ if (options.polling === void 0 || options.polling === true) {
917
+ polling = {
918
+ enabled: true,
919
+ intervalMs: DEFAULT_POLLING_INTERVAL_MS,
920
+ initTimeoutMs: DEFAULT_POLLING_INIT_TIMEOUT_MS
921
+ };
922
+ } else if (options.polling === false) {
923
+ polling = { enabled: false, intervalMs: 0, initTimeoutMs: 0 };
924
+ } else {
925
+ if (options.polling.intervalMs < MIN_POLLING_INTERVAL_MS) {
926
+ throw new Error(
927
+ `@vercel/flags-core: Polling interval must be at least ${MIN_POLLING_INTERVAL_MS}ms, got ${options.polling.intervalMs}ms.`
928
+ );
929
+ }
930
+ polling = {
931
+ enabled: true,
932
+ intervalMs: options.polling.intervalMs,
933
+ initTimeoutMs: options.polling.initTimeoutMs
934
+ };
935
+ }
936
+ return {
937
+ sdkKey: options.sdkKey,
938
+ datafile: options.datafile,
939
+ stream,
940
+ polling,
941
+ buildStep,
942
+ fetch: options.fetch ?? globalThis.fetch,
943
+ host: "https://flags.vercel.com"
944
+ };
945
+ }
946
+
947
+ // src/controller/typed-emitter.ts
948
+ var TypedEmitter = class {
949
+ handlers = /* @__PURE__ */ new Map();
950
+ on(event, handler) {
951
+ let set = this.handlers.get(event);
952
+ if (!set) {
953
+ set = /* @__PURE__ */ new Set();
954
+ this.handlers.set(event, set);
955
+ }
956
+ set.add(handler);
957
+ }
958
+ off(event, handler) {
959
+ this.handlers.get(event)?.delete(handler);
960
+ }
961
+ emit(event, ...args) {
962
+ const set = this.handlers.get(event);
963
+ if (set) {
964
+ for (const handler of set) {
965
+ handler(...args);
966
+ }
967
+ }
968
+ }
969
+ };
970
+
971
+ // src/controller/polling-source.ts
972
+ var PollingSource = class extends TypedEmitter {
973
+ config;
974
+ intervalId;
975
+ abortController;
976
+ constructor(config) {
977
+ super();
978
+ this.config = config;
979
+ }
980
+ /**
981
+ * Perform a single poll request.
982
+ * Emits 'data' on success, 'error' on failure.
983
+ */
984
+ async poll() {
985
+ if (this.abortController?.signal.aborted) return;
986
+ try {
987
+ const data = await fetchDatafile({
988
+ ...this.config,
989
+ signal: this.abortController?.signal
990
+ });
991
+ this.emit("data", data);
712
992
  } catch (error) {
713
- debugLog("@vercel/flags-core: Error sending events:", error);
993
+ const err = error instanceof Error ? error : new Error("Unknown poll error");
994
+ this.emit("error", err);
995
+ }
996
+ }
997
+ /**
998
+ * Start interval-based polling.
999
+ * Polls at the configured interval. Does not perform an initial poll —
1000
+ * callers should call poll() first if an immediate poll is needed.
1001
+ */
1002
+ startInterval() {
1003
+ if (this.intervalId) return;
1004
+ this.abortController = new AbortController();
1005
+ this.intervalId = setInterval(
1006
+ () => void this.poll(),
1007
+ this.config.polling.intervalMs
1008
+ );
1009
+ }
1010
+ /**
1011
+ * Stop interval-based polling.
1012
+ */
1013
+ stop() {
1014
+ if (this.intervalId) {
1015
+ clearInterval(this.intervalId);
1016
+ this.intervalId = void 0;
714
1017
  }
1018
+ this.abortController?.abort();
1019
+ this.abortController = void 0;
715
1020
  }
716
1021
  };
717
1022
 
718
- // src/data-source/stream-connection.ts
1023
+ // src/utils/sleep.ts
1024
+ function sleep(ms) {
1025
+ return new Promise((resolve) => setTimeout(resolve, ms));
1026
+ }
1027
+
1028
+ // src/controller/stream-connection.ts
719
1029
  var MAX_RETRY_COUNT = 15;
720
- var BASE_DELAY_MS = 1e3;
721
- var MAX_DELAY_MS = 6e4;
1030
+ var BASE_RETRY_DELAY_MS = 1e3;
1031
+ var MAX_RETRY_DELAY_MS = 6e4;
1032
+ var PING_TIMEOUT_MS = 9e4;
722
1033
  function backoff(retryCount) {
723
1034
  if (retryCount === 1) return 0;
724
- const delay = Math.min(BASE_DELAY_MS * 2 ** (retryCount - 2), MAX_DELAY_MS);
1035
+ const delay = Math.min(
1036
+ BASE_RETRY_DELAY_MS * 2 ** (retryCount - 2),
1037
+ MAX_RETRY_DELAY_MS
1038
+ );
725
1039
  return delay + Math.random() * 1e3;
726
1040
  }
1041
+ var UnauthorizedError = class extends Error {
1042
+ constructor() {
1043
+ super("stream: unauthorized (401)");
1044
+ this.name = "UnauthorizedError";
1045
+ }
1046
+ };
727
1047
  async function connectStream(config, callbacks) {
728
1048
  const {
729
1049
  host,
@@ -731,8 +1051,9 @@ async function connectStream(config, callbacks) {
731
1051
  abortController,
732
1052
  fetch: fetchFn = globalThis.fetch
733
1053
  } = config;
734
- const { onMessage, onDisconnect } = callbacks;
1054
+ const { onDatafile, onPrimed, onDisconnect } = callbacks;
735
1055
  let retryCount = 0;
1056
+ let lastAttemptTime = 0;
736
1057
  let resolveInit;
737
1058
  let rejectInit;
738
1059
  const initPromise = new Promise((resolve, reject) => {
@@ -744,183 +1065,267 @@ async function connectStream(config, callbacks) {
744
1065
  while (!abortController.signal.aborted) {
745
1066
  if (retryCount > MAX_RETRY_COUNT) {
746
1067
  console.error("@vercel/flags-core: Max retry count exceeded");
1068
+ if (!initialDataReceived) {
1069
+ rejectInit(
1070
+ new Error("stream: max retry count exceeded before receiving data")
1071
+ );
1072
+ }
747
1073
  abortController.abort();
748
1074
  break;
749
1075
  }
1076
+ const connectionAbort = new AbortController();
1077
+ const onMainAbort = () => connectionAbort.abort();
1078
+ abortController.signal.addEventListener("abort", onMainAbort, {
1079
+ once: true
1080
+ });
1081
+ let pingTimeoutId;
1082
+ let responseBody;
1083
+ const resetPingTimeout = () => {
1084
+ if (pingTimeoutId !== void 0) clearTimeout(pingTimeoutId);
1085
+ if (!initialDataReceived) return;
1086
+ pingTimeoutId = setTimeout(() => {
1087
+ responseBody?.cancel().catch(() => {
1088
+ });
1089
+ connectionAbort.abort();
1090
+ }, PING_TIMEOUT_MS);
1091
+ };
750
1092
  try {
1093
+ lastAttemptTime = Date.now();
1094
+ const headers = {
1095
+ Authorization: `Bearer ${sdkKey}`,
1096
+ "User-Agent": `VercelFlagsCore/${version}`,
1097
+ "X-Retry-Attempt": String(retryCount)
1098
+ };
1099
+ const revision = config.revision?.();
1100
+ if (revision !== void 0) {
1101
+ headers["X-Revision"] = String(revision);
1102
+ }
751
1103
  const response = await fetchFn(`${host}/v1/stream`, {
752
- headers: {
753
- Authorization: `Bearer ${sdkKey}`,
754
- "User-Agent": `VercelFlagsCore/${version}`,
755
- "X-Retry-Attempt": String(retryCount)
756
- },
757
- signal: abortController.signal
1104
+ headers,
1105
+ signal: connectionAbort.signal
758
1106
  });
759
1107
  if (!response.ok) {
760
1108
  if (response.status === 401) {
1109
+ if (!initialDataReceived) {
1110
+ rejectInit(new UnauthorizedError());
1111
+ }
761
1112
  abortController.abort();
1113
+ break;
762
1114
  }
763
1115
  throw new Error(`stream was not ok: ${response.status}`);
764
1116
  }
765
1117
  if (!response.body) {
766
1118
  throw new Error("stream body was not present");
767
1119
  }
1120
+ responseBody = response.body;
1121
+ const reader = response.body.getReader();
768
1122
  const decoder = new TextDecoder();
769
- let buffer = "";
770
- for await (const chunk of response.body) {
771
- if (abortController.signal.aborted) break;
772
- buffer += decoder.decode(chunk, { stream: true });
773
- const lines = buffer.split("\n");
774
- buffer = lines.pop();
775
- for (const line of lines) {
776
- if (line === "") continue;
777
- let message;
778
- try {
779
- message = JSON.parse(line);
780
- } catch {
781
- console.warn(
782
- "@vercel/flags-core: Failed to parse stream message, skipping"
783
- );
784
- continue;
785
- }
786
- if (message.type === "datafile") {
787
- onMessage(message.data);
788
- retryCount = 0;
789
- if (!initialDataReceived) {
790
- initialDataReceived = true;
791
- resolveInit();
1123
+ const bufferChunks = [];
1124
+ const onConnectionAbort = () => {
1125
+ reader.cancel().catch(() => {
1126
+ });
1127
+ };
1128
+ connectionAbort.signal.addEventListener("abort", onConnectionAbort, {
1129
+ once: true
1130
+ });
1131
+ try {
1132
+ while (true) {
1133
+ const { done, value: chunk } = await reader.read();
1134
+ if (done || abortController.signal.aborted) break;
1135
+ bufferChunks.push(decoder.decode(chunk, { stream: true }));
1136
+ const combined = bufferChunks.join("");
1137
+ bufferChunks.length = 0;
1138
+ const lines = combined.split("\n");
1139
+ bufferChunks.push(lines.pop());
1140
+ for (const line of lines) {
1141
+ if (line === "") continue;
1142
+ let message;
1143
+ try {
1144
+ message = JSON.parse(line);
1145
+ } catch {
1146
+ console.warn(
1147
+ "@vercel/flags-core: Failed to parse stream message, skipping"
1148
+ );
1149
+ continue;
1150
+ }
1151
+ if (message.type === "datafile") {
1152
+ onDatafile(message.data);
1153
+ retryCount = 0;
1154
+ if (!initialDataReceived) {
1155
+ initialDataReceived = true;
1156
+ resolveInit();
1157
+ }
1158
+ resetPingTimeout();
1159
+ }
1160
+ if (message.type === "primed") {
1161
+ onPrimed?.(message);
1162
+ retryCount = 0;
1163
+ if (!initialDataReceived) {
1164
+ initialDataReceived = true;
1165
+ resolveInit();
1166
+ }
1167
+ resetPingTimeout();
1168
+ }
1169
+ if (message.type === "ping" && initialDataReceived) {
1170
+ retryCount = 0;
1171
+ resetPingTimeout();
792
1172
  }
793
1173
  }
794
1174
  }
1175
+ } finally {
1176
+ connectionAbort.signal.removeEventListener(
1177
+ "abort",
1178
+ onConnectionAbort
1179
+ );
795
1180
  }
1181
+ clearTimeout(pingTimeoutId);
1182
+ abortController.signal.removeEventListener("abort", onMainAbort);
796
1183
  if (!abortController.signal.aborted) {
797
1184
  onDisconnect?.();
798
1185
  retryCount++;
799
- await sleep(backoff(retryCount));
1186
+ const elapsed = Date.now() - lastAttemptTime;
1187
+ const minGap = Math.max(0, BASE_RETRY_DELAY_MS - elapsed);
1188
+ await sleep(Math.max(backoff(retryCount), minGap));
800
1189
  continue;
801
1190
  }
802
1191
  } catch (error) {
1192
+ clearTimeout(pingTimeoutId);
1193
+ abortController.signal.removeEventListener("abort", onMainAbort);
803
1194
  if (abortController.signal.aborted) {
804
1195
  break;
805
1196
  }
806
- console.error("@vercel/flags-core: Stream error", error);
807
- onDisconnect?.();
808
- if (!initialDataReceived) {
809
- rejectInit(error);
810
- break;
1197
+ if (!connectionAbort.signal.aborted) {
1198
+ console.error("@vercel/flags-core: Stream error", error);
811
1199
  }
1200
+ onDisconnect?.();
812
1201
  retryCount++;
813
- await sleep(backoff(retryCount));
1202
+ const elapsed = Date.now() - lastAttemptTime;
1203
+ const minGap = Math.max(0, BASE_RETRY_DELAY_MS - elapsed);
1204
+ await sleep(Math.max(backoff(retryCount), minGap));
814
1205
  }
815
1206
  }
1207
+ if (!initialDataReceived) {
1208
+ rejectInit(new Error("stream: aborted before receiving data"));
1209
+ }
816
1210
  })();
817
1211
  return initPromise;
818
1212
  }
819
1213
 
820
- // src/data-source/flag-network-data-source.ts
821
- var FLAGS_HOST = "https://flags.vercel.com";
822
- var DEFAULT_STREAM_INIT_TIMEOUT_MS = 3e3;
823
- var DEFAULT_POLLING_INTERVAL_MS = 3e4;
824
- var DEFAULT_POLLING_INIT_TIMEOUT_MS = 3e3;
825
- var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
826
- var MAX_FETCH_RETRIES = 3;
827
- var FETCH_RETRY_BASE_DELAY_MS = 500;
828
- function normalizeOptions(options) {
829
- const autoDetectedBuildStep = process.env.CI === "1" || process.env.NEXT_PHASE === "phase-production-build";
830
- const buildStep = options.buildStep ?? autoDetectedBuildStep;
831
- let stream;
832
- if (options.stream === void 0 || options.stream === true) {
833
- stream = { enabled: true, initTimeoutMs: DEFAULT_STREAM_INIT_TIMEOUT_MS };
834
- } else if (options.stream === false) {
835
- stream = { enabled: false, initTimeoutMs: 0 };
836
- } else {
837
- stream = { enabled: true, initTimeoutMs: options.stream.initTimeoutMs };
838
- }
839
- let polling;
840
- if (options.polling === void 0 || options.polling === true) {
841
- polling = {
842
- enabled: true,
843
- intervalMs: DEFAULT_POLLING_INTERVAL_MS,
844
- initTimeoutMs: DEFAULT_POLLING_INIT_TIMEOUT_MS
845
- };
846
- } else if (options.polling === false) {
847
- polling = { enabled: false, intervalMs: 0, initTimeoutMs: 0 };
848
- } else {
849
- polling = {
850
- enabled: true,
851
- intervalMs: options.polling.intervalMs,
852
- initTimeoutMs: options.polling.initTimeoutMs
853
- };
1214
+ // src/controller/stream-source.ts
1215
+ var StreamSource = class extends TypedEmitter {
1216
+ options;
1217
+ revision;
1218
+ abortController;
1219
+ promise;
1220
+ constructor(options, revision) {
1221
+ super();
1222
+ this.options = options;
1223
+ this.revision = revision;
854
1224
  }
855
- return {
856
- sdkKey: options.sdkKey,
857
- datafile: options.datafile,
858
- stream,
859
- polling,
860
- buildStep,
861
- fetch: options.fetch ?? globalThis.fetch
862
- };
863
- }
864
- async function fetchDatafile(host, sdkKey, fetchFn) {
865
- let lastError;
866
- for (let attempt = 0; attempt < MAX_FETCH_RETRIES; attempt++) {
867
- const controller = new AbortController();
868
- const timeoutId = setTimeout(
869
- () => controller.abort(),
870
- DEFAULT_FETCH_TIMEOUT_MS
1225
+ /**
1226
+ * Start the stream connection.
1227
+ * Returns a promise that resolves when the first datafile or primed message arrives.
1228
+ * If already started, returns the existing promise.
1229
+ */
1230
+ start() {
1231
+ if (this.promise) return this.promise;
1232
+ const abortController = new AbortController();
1233
+ this.abortController = abortController;
1234
+ abortController.signal.addEventListener(
1235
+ "abort",
1236
+ () => {
1237
+ if (this.abortController === abortController) {
1238
+ this.promise = void 0;
1239
+ this.abortController = void 0;
1240
+ }
1241
+ },
1242
+ { once: true }
871
1243
  );
872
- let shouldRetry = true;
873
1244
  try {
874
- const res = await fetchFn(`${host}/v1/datafile`, {
875
- headers: {
876
- Authorization: `Bearer ${sdkKey}`,
877
- "User-Agent": `VercelFlagsCore/${version}`
1245
+ const promise = connectStream(
1246
+ {
1247
+ host: this.options.host,
1248
+ sdkKey: this.options.sdkKey,
1249
+ abortController,
1250
+ fetch: this.options.fetch,
1251
+ revision: this.revision
878
1252
  },
879
- signal: controller.signal
880
- });
881
- clearTimeout(timeoutId);
882
- if (!res.ok) {
883
- if (res.status >= 400 && res.status < 500 && res.status !== 429) {
884
- shouldRetry = false;
1253
+ {
1254
+ onDatafile: (newData) => {
1255
+ this.emit("data", newData);
1256
+ this.emit("connected");
1257
+ },
1258
+ onPrimed: (message) => {
1259
+ this.emit("primed", message);
1260
+ this.emit("connected");
1261
+ },
1262
+ onDisconnect: () => {
1263
+ this.emit("disconnected");
1264
+ }
885
1265
  }
886
- throw new Error(`Failed to fetch data: ${res.statusText}`);
887
- }
888
- return res.json();
1266
+ );
1267
+ this.promise = promise;
1268
+ return promise;
889
1269
  } catch (error) {
890
- clearTimeout(timeoutId);
891
- lastError = error instanceof Error ? error : new Error("Unknown fetch error");
892
- if (!shouldRetry) throw lastError;
893
- if (attempt < MAX_FETCH_RETRIES - 1) {
894
- const delay = FETCH_RETRY_BASE_DELAY_MS * 2 ** attempt + Math.random() * 500;
895
- await sleep(delay);
896
- }
1270
+ this.promise = void 0;
1271
+ this.abortController = void 0;
1272
+ throw error;
897
1273
  }
898
1274
  }
899
- throw lastError ?? new Error("Failed to fetch data after retries");
1275
+ /**
1276
+ * Stop the stream connection.
1277
+ */
1278
+ stop() {
1279
+ this.abortController?.abort();
1280
+ this.abortController = void 0;
1281
+ this.promise = void 0;
1282
+ }
1283
+ };
1284
+
1285
+ // src/controller/tagged-data.ts
1286
+ function tagData(data, origin) {
1287
+ return Object.assign(data, { _origin: origin });
1288
+ }
1289
+ function originToMetricsSource(origin) {
1290
+ switch (origin) {
1291
+ case "stream":
1292
+ case "poll":
1293
+ case "provided":
1294
+ return "in-memory";
1295
+ case "fetched":
1296
+ return "remote";
1297
+ case "bundled":
1298
+ return "embedded";
1299
+ }
1300
+ }
1301
+
1302
+ // src/controller/index.ts
1303
+ function parseConfigUpdatedAt(value) {
1304
+ if (typeof value === "number") return value;
1305
+ if (typeof value === "string") {
1306
+ const parsed = Number(value);
1307
+ return Number.isNaN(parsed) ? void 0 : parsed;
1308
+ }
1309
+ return void 0;
900
1310
  }
901
- var FlagNetworkDataSource = class _FlagNetworkDataSource {
1311
+ var Controller = class {
902
1312
  options;
903
- host = FLAGS_HOST;
904
- // Data state
1313
+ // State machine
1314
+ state = "idle";
1315
+ // Data state — tagged with origin
905
1316
  data;
906
- bundledDefinitionsPromise;
907
- // Stream state
908
- streamAbortController;
909
- streamPromise;
910
- isStreamConnected = false;
911
- hasWarnedAboutStaleData = false;
912
- // Polling state
913
- pollingIntervalId;
914
- pollingAbortController;
915
- // Initialization state — suppresses onDisconnect from starting polling
916
- // while initialize() is still running its own fallback chain
917
- isInitializing = false;
1317
+ // Sources (I/O delegates)
1318
+ streamSource;
1319
+ pollingSource;
1320
+ bundledSource;
918
1321
  // Usage tracking
919
1322
  usageTracker;
920
1323
  isFirstGetData = true;
921
- /**
922
- * Creates a new FlagNetworkDataSource instance.
923
- */
1324
+ // Build-step deduplication
1325
+ buildDataPromise = null;
1326
+ buildReadTracked = false;
1327
+ // Suppresses usage tracking when the SDK key is unauthorized
1328
+ unauthorized = false;
924
1329
  constructor(options) {
925
1330
  if (!options.sdkKey || typeof options.sdkKey !== "string" || !options.sdkKey.startsWith("vf_")) {
926
1331
  throw new Error(
@@ -928,16 +1333,88 @@ var FlagNetworkDataSource = class _FlagNetworkDataSource {
928
1333
  );
929
1334
  }
930
1335
  this.options = normalizeOptions(options);
931
- this.bundledDefinitionsPromise = readBundledDefinitions(
932
- this.options.sdkKey
1336
+ this.streamSource = new StreamSource(
1337
+ this.options,
1338
+ () => this.data?.revision
933
1339
  );
934
- if (this.options.datafile) {
935
- this.data = this.options.datafile;
936
- }
937
- this.usageTracker = new UsageTracker({
1340
+ this.pollingSource = new PollingSource(this.options);
1341
+ this.bundledSource = new BundledSource({
938
1342
  sdkKey: this.options.sdkKey,
939
- host: this.host
1343
+ readBundledDefinitions
940
1344
  });
1345
+ this.wireSourceEvents();
1346
+ if (this.options.datafile) {
1347
+ this.data = tagData(this.options.datafile, "provided");
1348
+ }
1349
+ this.usageTracker = new UsageTracker(this.options);
1350
+ }
1351
+ // Source event handlers (stored for cleanup)
1352
+ onStreamData = (data) => {
1353
+ if (this.isNewerData(data)) {
1354
+ this.data = tagData(data, "stream");
1355
+ }
1356
+ };
1357
+ onStreamPrimed = () => {
1358
+ if (this.state === "degraded" || this.state === "initializing:stream") {
1359
+ this.transition("streaming");
1360
+ }
1361
+ };
1362
+ onStreamConnected = () => {
1363
+ if (this.state === "degraded" || this.state === "initializing:stream") {
1364
+ this.transition("streaming");
1365
+ }
1366
+ };
1367
+ onStreamDisconnected = () => {
1368
+ if (this.state === "streaming") {
1369
+ this.transition("degraded");
1370
+ }
1371
+ };
1372
+ onPollData = (data) => {
1373
+ if (this.isNewerData(data)) {
1374
+ this.data = tagData(data, "poll");
1375
+ }
1376
+ };
1377
+ onPollError = (error) => {
1378
+ console.error("@vercel/flags-core: Poll failed:", error);
1379
+ };
1380
+ // ---------------------------------------------------------------------------
1381
+ // Source event wiring
1382
+ // ---------------------------------------------------------------------------
1383
+ wireSourceEvents() {
1384
+ this.streamSource.on("data", this.onStreamData);
1385
+ this.streamSource.on("primed", this.onStreamPrimed);
1386
+ this.streamSource.on("connected", this.onStreamConnected);
1387
+ this.streamSource.on("disconnected", this.onStreamDisconnected);
1388
+ this.pollingSource.on("data", this.onPollData);
1389
+ this.pollingSource.on("error", this.onPollError);
1390
+ }
1391
+ unwireSourceEvents() {
1392
+ this.streamSource.off("data", this.onStreamData);
1393
+ this.streamSource.off("primed", this.onStreamPrimed);
1394
+ this.streamSource.off("connected", this.onStreamConnected);
1395
+ this.streamSource.off("disconnected", this.onStreamDisconnected);
1396
+ this.pollingSource.off("data", this.onPollData);
1397
+ this.pollingSource.off("error", this.onPollError);
1398
+ }
1399
+ // ---------------------------------------------------------------------------
1400
+ // State machine
1401
+ // ---------------------------------------------------------------------------
1402
+ transition(to) {
1403
+ this.state = to;
1404
+ }
1405
+ get isConnected() {
1406
+ return this.state === "streaming";
1407
+ }
1408
+ get mode() {
1409
+ if (this.options.buildStep) return "build";
1410
+ switch (this.state) {
1411
+ case "streaming":
1412
+ return "streaming";
1413
+ case "polling":
1414
+ return "polling";
1415
+ default:
1416
+ return "offline";
1417
+ }
941
1418
  }
942
1419
  // ---------------------------------------------------------------------------
943
1420
  // Public API (DataSource interface)
@@ -945,155 +1422,187 @@ var FlagNetworkDataSource = class _FlagNetworkDataSource {
945
1422
  /**
946
1423
  * Initializes the data source.
947
1424
  *
948
- * Build step: datafile → bundled → fetch
949
- * Runtime: stream → poll → datafile → bundled
1425
+ * Build step: datafile → bundled → one-time fetch
1426
+ * Streaming mode: stream → datafile → bundled
1427
+ * Polling mode (no stream): poll → datafile → bundled
1428
+ * Offline mode (neither): datafile → bundled → one-time fetch
950
1429
  */
951
1430
  async initialize() {
952
1431
  if (this.options.buildStep) {
1432
+ this.transition("build:loading");
953
1433
  await this.initializeForBuildStep();
1434
+ this.transition("build:ready");
954
1435
  return;
955
1436
  }
956
1437
  if (!this.data && this.options.datafile) {
957
- this.data = this.options.datafile;
1438
+ this.data = tagData(this.options.datafile, "provided");
1439
+ }
1440
+ if (!this.data) {
1441
+ try {
1442
+ const bundled = await this.bundledSource.tryLoad();
1443
+ if (bundled) {
1444
+ this.data = tagData(bundled, "bundled");
1445
+ }
1446
+ } catch {
1447
+ }
958
1448
  }
959
1449
  if (this.data) {
960
- this.startBackgroundUpdates();
1450
+ if (this.options.stream.enabled) {
1451
+ this.transition("initializing:stream");
1452
+ await this.tryInitializeStream();
1453
+ } else if (this.options.polling.enabled) {
1454
+ this.transition("initializing:polling");
1455
+ await this.tryInitializePolling();
1456
+ } else {
1457
+ this.transition("degraded");
1458
+ }
961
1459
  return;
962
1460
  }
963
- this.isInitializing = true;
964
- try {
965
- if (this.options.stream.enabled) {
966
- const streamSuccess = await this.tryInitializeStream();
967
- if (streamSuccess) return;
1461
+ if (this.options.stream.enabled) {
1462
+ this.transition("initializing:stream");
1463
+ const streamSuccess = await this.tryInitializeStream();
1464
+ if (streamSuccess) {
1465
+ this.transition("streaming");
1466
+ return;
968
1467
  }
969
- if (this.options.polling.enabled) {
970
- const pollingSuccess = await this.tryInitializePolling();
971
- if (pollingSuccess) return;
1468
+ } else if (this.options.polling.enabled) {
1469
+ this.transition("initializing:polling");
1470
+ const pollingSuccess = await this.tryInitializePolling();
1471
+ if (pollingSuccess) {
1472
+ this.transition("polling");
1473
+ return;
972
1474
  }
973
- if (this.data) return;
974
- await this.initializeFromBundled();
975
- } finally {
976
- this.isInitializing = false;
977
1475
  }
1476
+ await this.initializeFromFallbacks();
978
1477
  }
979
1478
  /**
980
1479
  * Reads the current datafile with metrics.
981
1480
  */
982
1481
  async read() {
983
1482
  const startTime = Date.now();
984
- const cachedData = this.data;
985
- const cacheHadDefinitions = cachedData !== void 0;
1483
+ const cacheHadDefinitions = this.data !== void 0;
986
1484
  const isFirstRead = this.isFirstGetData;
987
1485
  this.isFirstGetData = false;
988
- let result;
989
- let source;
990
- let cacheStatus;
991
- if (this.options.buildStep) {
992
- [result, source, cacheStatus] = await this.getDataForBuildStep();
993
- } else if (cachedData) {
994
- [result, source, cacheStatus] = this.getDataFromCache(cachedData);
995
- } else {
996
- [result, source, cacheStatus] = await this.getDataWithFallbacks();
997
- }
1486
+ const [result, cacheStatus] = await this.resolveData();
998
1487
  const readMs = Date.now() - startTime;
1488
+ const { _origin, ...data } = result;
1489
+ const source = originToMetricsSource(_origin);
999
1490
  this.trackRead(startTime, cacheHadDefinitions, isFirstRead, source);
1000
- return Object.assign(result, {
1491
+ return {
1492
+ ...data,
1001
1493
  metrics: {
1002
1494
  readMs,
1003
1495
  source,
1004
1496
  cacheStatus,
1005
- connectionState: this.isStreamConnected ? "connected" : "disconnected"
1497
+ connectionState: this.isConnected ? "connected" : "disconnected",
1498
+ mode: this.mode
1006
1499
  }
1007
- });
1500
+ };
1008
1501
  }
1009
1502
  /**
1010
1503
  * Shuts down the data source and releases resources.
1011
1504
  */
1012
1505
  async shutdown() {
1013
- this.stopStream();
1014
- this.stopPolling();
1015
- this.data = this.options.datafile;
1016
- this.isInitializing = false;
1017
- this.isStreamConnected = false;
1018
- this.hasWarnedAboutStaleData = false;
1506
+ this.unwireSourceEvents();
1507
+ this.streamSource.stop();
1508
+ this.pollingSource.stop();
1509
+ this.data = this.options.datafile ? tagData(this.options.datafile, "provided") : void 0;
1510
+ this.transition("shutdown");
1019
1511
  await this.usageTracker.flush();
1020
1512
  }
1021
1513
  /**
1022
1514
  * Returns the datafile with metrics.
1023
- *
1024
- * During builds this will read from the bundled file if available.
1025
- *
1026
- * This method never opens a streaming connection, but will read from
1027
- * the stream if it is already open. Otherwise it fetches over the network.
1515
+ * Uses in-memory data if available, otherwise falls back to bundled,
1516
+ * then to a one-time fetch if called without prior initialization.
1028
1517
  */
1029
1518
  async getDatafile() {
1030
1519
  const startTime = Date.now();
1520
+ this.isFirstGetData = false;
1031
1521
  let result;
1032
- let source;
1033
1522
  let cacheStatus;
1034
1523
  if (this.options.buildStep) {
1035
- [result, source, cacheStatus] = await this.getDataForBuildStep();
1036
- } else if (this.isStreamConnected && this.data) {
1037
- [result, source, cacheStatus] = this.getDataFromCache();
1524
+ [result, cacheStatus] = await this.resolveDataForBuildStep();
1525
+ } else if (this.data) {
1526
+ cacheStatus = this.isConnected ? "HIT" : "STALE";
1527
+ result = this.data;
1038
1528
  } else {
1039
- const fetched = await fetchDatafile(
1040
- this.host,
1041
- this.options.sdkKey,
1042
- this.options.fetch
1043
- );
1044
- if (this.isNewerData(fetched)) {
1045
- this.data = fetched;
1529
+ const bundled = await this.bundledSource.tryLoad();
1530
+ if (bundled) {
1531
+ this.data = tagData(bundled, "bundled");
1532
+ result = this.data;
1533
+ cacheStatus = "MISS";
1534
+ } else {
1535
+ try {
1536
+ const fetched = await fetchDatafile({
1537
+ host: this.options.host,
1538
+ sdkKey: this.options.sdkKey,
1539
+ fetch: this.options.fetch
1540
+ });
1541
+ this.data = tagData(fetched, "fetched");
1542
+ result = this.data;
1543
+ cacheStatus = "MISS";
1544
+ } catch {
1545
+ throw new Error(
1546
+ "@vercel/flags-core: No flag definitions available. Initialize the client or provide a datafile."
1547
+ );
1548
+ }
1046
1549
  }
1047
- [result, source, cacheStatus] = [this.data ?? fetched, "remote", "MISS"];
1048
1550
  }
1049
- return Object.assign(result, {
1551
+ const source = originToMetricsSource(result._origin);
1552
+ return {
1553
+ ...result,
1050
1554
  metrics: {
1051
1555
  readMs: Date.now() - startTime,
1052
1556
  source,
1053
1557
  cacheStatus,
1054
- connectionState: this.isStreamConnected ? "connected" : "disconnected"
1558
+ connectionState: this.isConnected ? "connected" : "disconnected",
1559
+ mode: this.mode
1055
1560
  }
1056
- });
1561
+ };
1057
1562
  }
1058
1563
  /**
1059
1564
  * Returns the bundled fallback datafile.
1060
1565
  */
1061
1566
  async getFallbackDatafile() {
1062
- if (!this.bundledDefinitionsPromise) {
1063
- throw new FallbackNotFoundError();
1064
- }
1065
- const bundledResult = await this.bundledDefinitionsPromise;
1066
- if (!bundledResult) {
1067
- throw new FallbackNotFoundError();
1567
+ return this.bundledSource.getRaw();
1568
+ }
1569
+ // ---------------------------------------------------------------------------
1570
+ // Data resolution (shared by read() and getDatafile())
1571
+ // ---------------------------------------------------------------------------
1572
+ /**
1573
+ * Resolves the current data, using the appropriate strategy for the
1574
+ * current mode. Returns tagged data and cache status.
1575
+ *
1576
+ * Build step: cached → bundled → one-time fetch
1577
+ * Runtime with cache: return cached data
1578
+ * Runtime without cache: stream/poll → datafile → bundled → fetch → throw
1579
+ */
1580
+ async resolveData() {
1581
+ if (this.options.buildStep) {
1582
+ return this.resolveDataForBuildStep();
1068
1583
  }
1069
- switch (bundledResult.state) {
1070
- case "ok":
1071
- return bundledResult.definitions;
1072
- case "missing-file":
1073
- throw new FallbackNotFoundError();
1074
- case "missing-entry":
1075
- throw new FallbackEntryNotFoundError();
1076
- case "unexpected-error":
1077
- throw new Error(
1078
- "@vercel/flags-core: Failed to read bundled definitions: " + String(bundledResult.error)
1079
- );
1584
+ if (this.data) {
1585
+ const cacheStatus = this.isConnected ? "HIT" : "STALE";
1586
+ return [this.data, cacheStatus];
1080
1587
  }
1588
+ return this.resolveDataWithFallbacks();
1081
1589
  }
1082
1590
  // ---------------------------------------------------------------------------
1083
- // Stream management
1591
+ // Stream initialization
1084
1592
  // ---------------------------------------------------------------------------
1085
1593
  /**
1086
1594
  * Attempts to initialize via stream with timeout.
1087
1595
  * Returns true if stream connected successfully within timeout.
1088
1596
  */
1089
1597
  async tryInitializeStream() {
1090
- let streamPromise;
1091
1598
  if (this.options.stream.initTimeoutMs <= 0) {
1092
1599
  try {
1093
- streamPromise = this.startStream();
1094
- await streamPromise;
1600
+ await this.streamSource.start();
1095
1601
  return true;
1096
- } catch {
1602
+ } catch (error) {
1603
+ if (error instanceof UnauthorizedError) {
1604
+ this.unauthorized = true;
1605
+ }
1097
1606
  return false;
1098
1607
  }
1099
1608
  }
@@ -1105,87 +1614,44 @@ var FlagNetworkDataSource = class _FlagNetworkDataSource {
1105
1614
  );
1106
1615
  });
1107
1616
  try {
1108
- streamPromise = this.startStream();
1109
- const result = await Promise.race([streamPromise, timeoutPromise]);
1617
+ const result = await Promise.race([
1618
+ this.streamSource.start(),
1619
+ timeoutPromise
1620
+ ]);
1110
1621
  clearTimeout(timeoutId);
1111
1622
  if (result === "timeout") {
1112
1623
  console.warn(
1113
1624
  "@vercel/flags-core: Stream initialization timeout, falling back"
1114
1625
  );
1626
+ void this.streamSource.start().catch(() => {
1627
+ });
1115
1628
  return false;
1116
1629
  }
1117
1630
  return true;
1118
- } catch {
1631
+ } catch (error) {
1119
1632
  clearTimeout(timeoutId);
1633
+ if (error instanceof Error && error.message.includes("401")) {
1634
+ this.unauthorized = true;
1635
+ }
1120
1636
  return false;
1121
1637
  }
1122
1638
  }
1123
- /**
1124
- * Starts the stream connection with callbacks for data and disconnect.
1125
- */
1126
- startStream() {
1127
- if (this.streamPromise) return this.streamPromise;
1128
- this.streamAbortController = new AbortController();
1129
- this.isStreamConnected = false;
1130
- this.hasWarnedAboutStaleData = false;
1131
- try {
1132
- const streamPromise = connectStream(
1133
- {
1134
- host: this.host,
1135
- sdkKey: this.options.sdkKey,
1136
- abortController: this.streamAbortController,
1137
- fetch: this.options.fetch
1138
- },
1139
- {
1140
- onMessage: (newData) => {
1141
- if (this.isNewerData(newData)) {
1142
- this.data = newData;
1143
- }
1144
- this.isStreamConnected = true;
1145
- this.hasWarnedAboutStaleData = false;
1146
- if (this.pollingIntervalId) {
1147
- this.stopPolling();
1148
- }
1149
- },
1150
- onDisconnect: () => {
1151
- this.isStreamConnected = false;
1152
- if (this.options.polling.enabled && !this.pollingIntervalId && !this.isInitializing) {
1153
- this.startPolling();
1154
- }
1155
- }
1156
- }
1157
- );
1158
- this.streamPromise = streamPromise;
1159
- return streamPromise;
1160
- } catch (error) {
1161
- this.streamPromise = void 0;
1162
- this.streamAbortController = void 0;
1163
- throw error;
1164
- }
1165
- }
1166
- /**
1167
- * Stops the stream connection.
1168
- */
1169
- stopStream() {
1170
- this.streamAbortController?.abort();
1171
- this.streamAbortController = void 0;
1172
- this.streamPromise = void 0;
1173
- }
1174
1639
  // ---------------------------------------------------------------------------
1175
- // Polling management
1640
+ // Polling initialization
1176
1641
  // ---------------------------------------------------------------------------
1177
1642
  /**
1178
1643
  * Attempts to initialize via polling with timeout.
1179
1644
  * Returns true if first poll succeeded within timeout.
1645
+ *
1646
+ * Only used when streaming is disabled and polling is the primary source.
1180
1647
  */
1181
1648
  async tryInitializePolling() {
1182
- this.pollingAbortController = new AbortController();
1183
- const pollPromise = this.performPoll();
1649
+ const pollPromise = this.pollingSource.poll();
1184
1650
  if (this.options.polling.initTimeoutMs <= 0) {
1185
1651
  try {
1186
1652
  await pollPromise;
1187
1653
  if (this.data) {
1188
- this.startPollingInterval();
1654
+ this.pollingSource.startInterval();
1189
1655
  return true;
1190
1656
  }
1191
1657
  return false;
@@ -1210,7 +1676,7 @@ var FlagNetworkDataSource = class _FlagNetworkDataSource {
1210
1676
  return false;
1211
1677
  }
1212
1678
  if (this.data) {
1213
- this.startPollingInterval();
1679
+ this.pollingSource.startInterval();
1214
1680
  return true;
1215
1681
  }
1216
1682
  return false;
@@ -1219,68 +1685,6 @@ var FlagNetworkDataSource = class _FlagNetworkDataSource {
1219
1685
  return false;
1220
1686
  }
1221
1687
  }
1222
- /**
1223
- * Starts polling (initial poll + interval).
1224
- */
1225
- startPolling() {
1226
- if (this.pollingIntervalId) return;
1227
- this.pollingAbortController = new AbortController();
1228
- void this.performPoll();
1229
- this.startPollingInterval();
1230
- }
1231
- /**
1232
- * Starts the polling interval (without initial poll).
1233
- */
1234
- startPollingInterval() {
1235
- if (this.pollingIntervalId) return;
1236
- this.pollingIntervalId = setInterval(
1237
- () => void this.performPoll(),
1238
- this.options.polling.intervalMs
1239
- );
1240
- }
1241
- /**
1242
- * Stops polling.
1243
- */
1244
- stopPolling() {
1245
- if (this.pollingIntervalId) {
1246
- clearInterval(this.pollingIntervalId);
1247
- this.pollingIntervalId = void 0;
1248
- }
1249
- this.pollingAbortController?.abort();
1250
- this.pollingAbortController = void 0;
1251
- }
1252
- /**
1253
- * Performs a single poll request.
1254
- */
1255
- async performPoll() {
1256
- if (this.pollingAbortController?.signal.aborted) return;
1257
- try {
1258
- const data = await fetchDatafile(
1259
- this.host,
1260
- this.options.sdkKey,
1261
- this.options.fetch
1262
- );
1263
- if (this.isNewerData(data)) {
1264
- this.data = data;
1265
- }
1266
- } catch (error) {
1267
- console.error("@vercel/flags-core: Poll failed:", error);
1268
- }
1269
- }
1270
- // ---------------------------------------------------------------------------
1271
- // Background updates
1272
- // ---------------------------------------------------------------------------
1273
- /**
1274
- * Starts background updates (stream or polling) without blocking.
1275
- * Used when we already have data from provided datafile.
1276
- */
1277
- startBackgroundUpdates() {
1278
- if (this.options.stream.enabled) {
1279
- void this.startStream();
1280
- } else if (this.options.polling.enabled) {
1281
- this.startPolling();
1282
- }
1283
- }
1284
1688
  // ---------------------------------------------------------------------------
1285
1689
  // Build step helpers
1286
1690
  // ---------------------------------------------------------------------------
@@ -1289,116 +1693,139 @@ var FlagNetworkDataSource = class _FlagNetworkDataSource {
1289
1693
  */
1290
1694
  async initializeForBuildStep() {
1291
1695
  if (this.data) return;
1292
- if (this.bundledDefinitionsPromise) {
1293
- const bundledResult = await this.bundledDefinitionsPromise;
1294
- if (bundledResult?.state === "ok" && bundledResult.definitions) {
1295
- this.data = bundledResult.definitions;
1296
- return;
1297
- }
1696
+ if (!this.buildDataPromise) {
1697
+ this.buildDataPromise = this.loadBuildData();
1298
1698
  }
1299
- this.data = await fetchDatafile(
1300
- this.host,
1301
- this.options.sdkKey,
1302
- this.options.fetch
1303
- );
1699
+ this.data = await this.buildDataPromise;
1304
1700
  }
1305
1701
  /**
1306
1702
  * Retrieves data during build steps.
1703
+ * Concurrent callers share a single load promise. The first caller to
1704
+ * populate `this.data` gets cacheStatus MISS; subsequent callers get HIT.
1307
1705
  */
1308
- async getDataForBuildStep() {
1706
+ async resolveDataForBuildStep() {
1309
1707
  if (this.data) {
1310
- return [this.data, "in-memory", "HIT"];
1708
+ return [this.data, "HIT"];
1311
1709
  }
1312
- if (this.bundledDefinitionsPromise) {
1313
- const bundledResult = await this.bundledDefinitionsPromise;
1314
- if (bundledResult?.state === "ok" && bundledResult.definitions) {
1315
- this.data = bundledResult.definitions;
1316
- return [this.data, "embedded", "MISS"];
1317
- }
1710
+ if (!this.buildDataPromise) {
1711
+ this.buildDataPromise = this.loadBuildData();
1712
+ }
1713
+ const data = await this.buildDataPromise;
1714
+ if (!this.data) {
1715
+ this.data = data;
1716
+ return [data, "MISS"];
1717
+ }
1718
+ return [this.data, "HIT"];
1719
+ }
1720
+ /**
1721
+ * Loads data for a build step: bundled → one-time fetch.
1722
+ */
1723
+ async loadBuildData() {
1724
+ const bundled = await this.bundledSource.tryLoad();
1725
+ if (bundled) return tagData(bundled, "bundled");
1726
+ try {
1727
+ const fetched = await fetchDatafile({
1728
+ host: this.options.host,
1729
+ sdkKey: this.options.sdkKey,
1730
+ fetch: this.options.fetch
1731
+ });
1732
+ return tagData(fetched, "fetched");
1733
+ } catch {
1318
1734
  }
1319
- this.data = await fetchDatafile(
1320
- this.host,
1321
- this.options.sdkKey,
1322
- this.options.fetch
1735
+ throw new Error(
1736
+ "@vercel/flags-core: No flag definitions available during build. Provide a datafile or bundled definitions."
1323
1737
  );
1324
- return [this.data, "remote", "MISS"];
1325
1738
  }
1326
1739
  // ---------------------------------------------------------------------------
1327
- // Runtime helpers
1740
+ // Fallback helpers
1328
1741
  // ---------------------------------------------------------------------------
1329
1742
  /**
1330
- * Returns data from the in-memory cache.
1743
+ * Shared fallback chain used by both initialize() and resolveData().
1331
1744
  */
1332
- getDataFromCache(cachedData) {
1333
- const data = cachedData ?? this.data;
1334
- this.warnIfDisconnected();
1335
- const cacheStatus = this.isStreamConnected ? "HIT" : "STALE";
1336
- return [data, "in-memory", cacheStatus];
1745
+ async initializeFromFallbacks() {
1746
+ this.transition("initializing:fallback");
1747
+ if (this.data) {
1748
+ this.transition("degraded");
1749
+ return;
1750
+ }
1751
+ const bundled = await this.bundledSource.tryLoad();
1752
+ if (bundled) {
1753
+ this.data = tagData(bundled, "bundled");
1754
+ this.transition("degraded");
1755
+ return;
1756
+ }
1757
+ if (!this.options.stream.enabled && !this.options.polling.enabled) {
1758
+ try {
1759
+ const fetched = await fetchDatafile({
1760
+ host: this.options.host,
1761
+ sdkKey: this.options.sdkKey,
1762
+ fetch: this.options.fetch
1763
+ });
1764
+ this.data = tagData(fetched, "fetched");
1765
+ this.transition("degraded");
1766
+ return;
1767
+ } catch {
1768
+ }
1769
+ }
1770
+ throw new Error(
1771
+ "@vercel/flags-core: No flag definitions available. Bundled definitions not found."
1772
+ );
1337
1773
  }
1338
1774
  /**
1339
- * Retrieves data using the fallback chain.
1775
+ * Retrieves data using the fallback chain (called when no cached data exists).
1776
+ * Streaming mode: stream → datafile → bundled.
1777
+ * Polling mode: poll → datafile → bundled.
1778
+ * Offline mode: datafile → bundled → one-time fetch.
1340
1779
  */
1341
- async getDataWithFallbacks() {
1780
+ async resolveDataWithFallbacks() {
1342
1781
  if (this.options.stream.enabled) {
1782
+ this.transition("initializing:stream");
1343
1783
  const streamSuccess = await this.tryInitializeStream();
1344
1784
  if (streamSuccess && this.data) {
1345
- return [this.data, "in-memory", "MISS"];
1785
+ this.transition("streaming");
1786
+ return [this.data, "MISS"];
1346
1787
  }
1347
- }
1348
- if (this.options.polling.enabled) {
1788
+ } else if (this.options.polling.enabled) {
1789
+ this.transition("initializing:polling");
1349
1790
  const pollingSuccess = await this.tryInitializePolling();
1350
1791
  if (pollingSuccess && this.data) {
1351
- return [this.data, "remote", "MISS"];
1792
+ this.transition("polling");
1793
+ return [this.data, "MISS"];
1352
1794
  }
1353
1795
  }
1796
+ this.transition("initializing:fallback");
1354
1797
  if (this.options.datafile) {
1355
- this.data = this.options.datafile;
1356
- return [this.data, "in-memory", "STALE"];
1798
+ this.data = tagData(this.options.datafile, "provided");
1799
+ this.transition("degraded");
1800
+ return [this.data, "STALE"];
1357
1801
  }
1358
- if (this.bundledDefinitionsPromise) {
1359
- const bundledResult = await this.bundledDefinitionsPromise;
1360
- if (bundledResult?.state === "ok" && bundledResult.definitions) {
1361
- console.warn(
1362
- "@vercel/flags-core: Using bundled definitions as fallback"
1363
- );
1364
- this.data = bundledResult.definitions;
1365
- return [this.data, "embedded", "STALE"];
1366
- }
1367
- }
1368
- throw new Error(
1369
- "@vercel/flags-core: No flag definitions available. Ensure streaming/polling is enabled or provide a datafile."
1370
- );
1371
- }
1372
- /**
1373
- * Initializes from bundled definitions.
1374
- */
1375
- async initializeFromBundled() {
1376
- if (!this.bundledDefinitionsPromise) {
1377
- throw new Error(
1378
- "@vercel/flags-core: No flag definitions available. Ensure streaming/polling is enabled or provide a datafile."
1379
- );
1802
+ const bundled = await this.bundledSource.tryLoad();
1803
+ if (bundled) {
1804
+ console.warn("@vercel/flags-core: Using bundled definitions as fallback");
1805
+ this.data = tagData(bundled, "bundled");
1806
+ this.transition("degraded");
1807
+ return [this.data, "STALE"];
1380
1808
  }
1381
- const bundledResult = await this.bundledDefinitionsPromise;
1382
- if (bundledResult?.state === "ok" && bundledResult.definitions) {
1383
- this.data = bundledResult.definitions;
1384
- return;
1809
+ if (!this.options.stream.enabled && !this.options.polling.enabled) {
1810
+ try {
1811
+ const fetched = await fetchDatafile({
1812
+ host: this.options.host,
1813
+ sdkKey: this.options.sdkKey,
1814
+ fetch: this.options.fetch
1815
+ });
1816
+ this.data = tagData(fetched, "fetched");
1817
+ this.transition("degraded");
1818
+ return [this.data, "MISS"];
1819
+ } catch {
1820
+ }
1385
1821
  }
1386
1822
  throw new Error(
1387
- "@vercel/flags-core: No flag definitions available. Bundled definitions not found."
1823
+ "@vercel/flags-core: No flag definitions available. Provide a datafile or bundled definitions."
1388
1824
  );
1389
1825
  }
1390
- /**
1391
- * Parses a configUpdatedAt value (number or string) into a numeric timestamp.
1392
- * Returns undefined if the value is missing or cannot be parsed.
1393
- */
1394
- static parseConfigUpdatedAt(value) {
1395
- if (typeof value === "number") return value;
1396
- if (typeof value === "string") {
1397
- const parsed = Number(value);
1398
- return Number.isNaN(parsed) ? void 0 : parsed;
1399
- }
1400
- return void 0;
1401
- }
1826
+ // ---------------------------------------------------------------------------
1827
+ // Data comparison
1828
+ // ---------------------------------------------------------------------------
1402
1829
  /**
1403
1830
  * Checks if the incoming data is newer than the current in-memory data.
1404
1831
  * Returns true if the update should proceed, false if it should be skipped.
@@ -1408,50 +1835,47 @@ var FlagNetworkDataSource = class _FlagNetworkDataSource {
1408
1835
  * - The current data has no configUpdatedAt
1409
1836
  * - The incoming data has no configUpdatedAt
1410
1837
  *
1411
- * Skips the update only when both have configUpdatedAt and incoming is older.
1838
+ * Skips the update only when both have configUpdatedAt and incoming is not newer.
1412
1839
  */
1413
1840
  isNewerData(incoming) {
1414
1841
  if (!this.data) return true;
1415
- const currentTs = _FlagNetworkDataSource.parseConfigUpdatedAt(
1416
- this.data.configUpdatedAt
1417
- );
1418
- const incomingTs = _FlagNetworkDataSource.parseConfigUpdatedAt(
1419
- incoming.configUpdatedAt
1420
- );
1842
+ const currentTs = parseConfigUpdatedAt(this.data.configUpdatedAt);
1843
+ const incomingTs = parseConfigUpdatedAt(incoming.configUpdatedAt);
1421
1844
  if (currentTs === void 0 || incomingTs === void 0) {
1422
1845
  return true;
1423
1846
  }
1424
- return incomingTs >= currentTs;
1425
- }
1426
- /**
1427
- * Logs a warning if returning cached data while stream is disconnected.
1428
- */
1429
- warnIfDisconnected() {
1430
- if (!this.isStreamConnected && !this.hasWarnedAboutStaleData) {
1431
- this.hasWarnedAboutStaleData = true;
1432
- console.warn(
1433
- "@vercel/flags-core: Returning in-memory flag definitions while stream is disconnected. Data may be stale."
1434
- );
1435
- }
1847
+ return incomingTs > currentTs;
1436
1848
  }
1437
1849
  // ---------------------------------------------------------------------------
1438
1850
  // Usage tracking
1439
1851
  // ---------------------------------------------------------------------------
1440
1852
  /**
1441
1853
  * Tracks a read operation for usage analytics.
1854
+ * During build steps, only the first read is tracked.
1442
1855
  */
1443
1856
  trackRead(startTime, cacheHadDefinitions, isFirstRead, source) {
1857
+ if (this.unauthorized) return;
1858
+ if (this.options.buildStep && this.buildReadTracked) return;
1859
+ if (this.options.buildStep) this.buildReadTracked = true;
1444
1860
  const configOrigin = source === "embedded" ? "embedded" : "in-memory";
1861
+ const cacheAction = this.state === "streaming" ? "FOLLOWING" : this.state === "polling" ? "REFRESHING" : "NONE";
1862
+ const mode = this.mode;
1445
1863
  const trackOptions = {
1446
1864
  configOrigin,
1447
1865
  cacheStatus: cacheHadDefinitions ? "HIT" : "MISS",
1866
+ cacheAction,
1448
1867
  cacheIsBlocking: !cacheHadDefinitions,
1449
- duration: Date.now() - startTime
1868
+ duration: Date.now() - startTime,
1869
+ mode: mode === "streaming" ? "stream" : mode === "polling" ? "poll" : mode
1450
1870
  };
1451
1871
  const configUpdatedAt = this.data?.configUpdatedAt;
1452
1872
  if (typeof configUpdatedAt === "number") {
1453
1873
  trackOptions.configUpdatedAt = configUpdatedAt;
1454
1874
  }
1875
+ const revision = this.data?.revision;
1876
+ if (typeof revision === "number") {
1877
+ trackOptions.revision = revision;
1878
+ }
1455
1879
  if (isFirstRead) {
1456
1880
  trackOptions.cacheIsFirstRead = true;
1457
1881
  }
@@ -1475,16 +1899,23 @@ function parseSdkKeyFromFlagsConnectionString(text) {
1475
1899
  function make(createRawClient) {
1476
1900
  let _defaultFlagsClient = null;
1477
1901
  function createClient2(sdkKeyOrConnectionString, options) {
1478
- if (!sdkKeyOrConnectionString) throw new Error("flags: Missing sdkKey");
1902
+ if (!sdkKeyOrConnectionString)
1903
+ throw new Error("@vercel/flags-core: Missing sdkKey");
1904
+ if (typeof sdkKeyOrConnectionString !== "string")
1905
+ throw new Error(
1906
+ `@vercel/flags-core: Invalid sdkKey. Expected string, got ${typeof sdkKeyOrConnectionString}`
1907
+ );
1479
1908
  const sdkKey = parseSdkKeyFromFlagsConnectionString(
1480
1909
  sdkKeyOrConnectionString
1481
1910
  );
1482
1911
  if (!sdkKey) {
1483
- throw new Error("flags: Missing sdkKey in connection string");
1912
+ throw new Error(
1913
+ "@vercel/flags-core: Missing sdkKey in connection string"
1914
+ );
1484
1915
  }
1485
- const dataSource = new FlagNetworkDataSource({ sdkKey, ...options });
1916
+ const controller = new Controller({ sdkKey, ...options });
1486
1917
  return createRawClient({
1487
- dataSource,
1918
+ controller,
1488
1919
  origin: { provider: "vercel", sdkKey }
1489
1920
  });
1490
1921
  }
@@ -1499,7 +1930,7 @@ function make(createRawClient) {
1499
1930
  }
1500
1931
  const sdkKey = parseSdkKeyFromFlagsConnectionString(process.env.FLAGS);
1501
1932
  if (!sdkKey) {
1502
- throw new Error("flags: Missing sdkKey");
1933
+ throw new Error("@vercel/flags-core: Missing sdkKey");
1503
1934
  }
1504
1935
  _defaultFlagsClient = createClient2(sdkKey);
1505
1936
  }
@@ -1530,16 +1961,16 @@ var {
1530
1961
  * Create a flags client based on an SDK Key
1531
1962
  */
1532
1963
  createClient
1533
- } = make(createCreateRawClient(client_fns_exports));
1964
+ } = make(createCreateRawClient(controller_fns_exports));
1534
1965
 
1535
1966
  export {
1536
1967
  ResolutionReason,
1537
1968
  evaluate,
1538
1969
  FallbackNotFoundError,
1539
1970
  FallbackEntryNotFoundError,
1540
- FlagNetworkDataSource,
1971
+ Controller,
1541
1972
  flagsClient,
1542
1973
  resetDefaultFlagsClient,
1543
1974
  createClient
1544
1975
  };
1545
- //# sourceMappingURL=chunk-EB6T2UJ6.js.map
1976
+ //# sourceMappingURL=chunk-J33BA25D.js.map