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