@vercel/flags-core 1.1.0-cea788b-20260305194413 → 1.1.1

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