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