@vercel/flags-core 1.0.1 → 1.1.0-3ca407e-20260306094417

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