@vercel/flags-core 1.9.0 → 1.10.0-0037f35-20261006132750

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 (33) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +184 -0
  3. package/dist/{chunk-TDZBUVBG.js → chunk-D4522FGO.js} +1295 -367
  4. package/dist/chunk-D4522FGO.js.map +1 -0
  5. package/dist/{chunk-PGKH4XQ7.cjs → chunk-ITB5XQD3.cjs} +1335 -407
  6. package/dist/chunk-ITB5XQD3.cjs.map +1 -0
  7. package/dist/{chunk-SLNLPFKU.js → chunk-SYXANNZ4.js} +1295 -367
  8. package/dist/chunk-SYXANNZ4.js.map +1 -0
  9. package/dist/{chunk-W742GGZU.cjs → chunk-W6776MKF.cjs} +1336 -408
  10. package/dist/chunk-W6776MKF.cjs.map +1 -0
  11. package/dist/index.default.cjs +2 -2
  12. package/dist/index.default.d.cts +104 -39
  13. package/dist/index.default.d.ts +104 -39
  14. package/dist/index.default.js +1 -1
  15. package/dist/index.next-js.cjs +2 -2
  16. package/dist/index.next-js.d.cts +104 -39
  17. package/dist/index.next-js.d.ts +104 -39
  18. package/dist/index.next-js.js +1 -1
  19. package/dist/openfeature.default.cjs +2 -2
  20. package/dist/openfeature.default.d.cts +1 -1
  21. package/dist/openfeature.default.d.ts +1 -1
  22. package/dist/openfeature.default.js +1 -1
  23. package/dist/openfeature.next-js.cjs +2 -2
  24. package/dist/openfeature.next-js.d.cts +1 -1
  25. package/dist/openfeature.next-js.d.ts +1 -1
  26. package/dist/openfeature.next-js.js +1 -1
  27. package/dist/{types-1OK52NNi.d.cts → types-DpVlT3fI.d.cts} +7 -1
  28. package/dist/{types-1OK52NNi.d.ts → types-DpVlT3fI.d.ts} +7 -1
  29. package/package.json +1 -1
  30. package/dist/chunk-PGKH4XQ7.cjs.map +0 -1
  31. package/dist/chunk-SLNLPFKU.js.map +0 -1
  32. package/dist/chunk-TDZBUVBG.js.map +0 -1
  33. package/dist/chunk-W742GGZU.cjs.map +0 -1
@@ -576,7 +576,7 @@ function bulkEvaluate(flags, shared) {
576
576
  }
577
577
 
578
578
  // package.json
579
- var version = "1.9.0";
579
+ var version = "1.10.0-0037f35-20261006132750";
580
580
 
581
581
  // src/lib/report-value.ts
582
582
  function internalReportValue(key, value, data) {
@@ -1045,25 +1045,16 @@ import { getVercelOidcToken } from "@vercel/oidc";
1045
1045
 
1046
1046
  // src/utils/sdk-keys.ts
1047
1047
  var SDK_KEY_REGEX = /^vf_(?:server|client)_/;
1048
- function isValidSdkKey(value) {
1049
- return SDK_KEY_REGEX.test(value);
1050
- }
1051
- var PROJECT_ID_REGEX = /^[A-Za-z0-9_]{1,64}$/;
1052
- function isValidProjectId(value) {
1053
- return PROJECT_ID_REGEX.test(value);
1054
- }
1055
- function parseFlagsConnectionString(text) {
1056
- if (SDK_KEY_REGEX.test(text)) return { sdkKey: text, projectId: null };
1057
- if (!text.startsWith("flags:")) return null;
1048
+ function parseSdkKeyFromFlagsConnectionString(text) {
1049
+ if (SDK_KEY_REGEX.test(text)) return text;
1058
1050
  try {
1051
+ if (!text.startsWith("flags:")) return null;
1059
1052
  const params = new URLSearchParams(text.slice(6));
1060
- return {
1061
- sdkKey: params.get("sdkKey") || null,
1062
- projectId: params.get("projectId") || null
1063
- };
1053
+ const sdkKey = params.get("sdkKey");
1054
+ if (sdkKey && SDK_KEY_REGEX.test(sdkKey)) return sdkKey;
1064
1055
  } catch {
1065
- return null;
1066
1056
  }
1057
+ return null;
1067
1058
  }
1068
1059
 
1069
1060
  // src/controller/auth.ts
@@ -1075,8 +1066,10 @@ function authHeaders(token, sourceProjectId) {
1075
1066
  };
1076
1067
  }
1077
1068
  function unauthorizedMessage(sourceProjectId) {
1078
- if (!sourceProjectId) return "unauthorized (401)";
1079
- return `unauthorized (401): this deployment is not allowed to read the flags of project "${sourceProjectId}". Check the connection string and that the project is connected.`;
1069
+ if (!sourceProjectId) {
1070
+ return "unauthorized (401)";
1071
+ }
1072
+ return `unauthorized (401): this deployment is not allowed to read the flags of project "${sourceProjectId}". Check that the project is connected.`;
1080
1073
  }
1081
1074
  async function getOidcToken() {
1082
1075
  try {
@@ -1116,27 +1109,13 @@ var Authentication = class {
1116
1109
  `@vercel/flags-core: Invalid sdkKey. Expected string, got ${typeof sdkKeyOrConnectionString}`
1117
1110
  );
1118
1111
  }
1119
- const parsed = parseFlagsConnectionString(sdkKeyOrConnectionString);
1120
- if (parsed?.sdkKey && parsed.projectId) {
1121
- throw new Error(
1122
- "@vercel/flags-core: A connection string must contain either sdkKey or projectId, not both"
1123
- );
1124
- }
1125
- if (parsed?.sdkKey) {
1126
- if (!isValidSdkKey(parsed.sdkKey)) {
1127
- throw new Error("@vercel/flags-core: Missing sdkKey");
1128
- }
1129
- this.sdkKey = parsed.sdkKey;
1130
- } else if (parsed?.projectId) {
1131
- if (!isValidProjectId(parsed.projectId)) {
1132
- throw new Error(
1133
- "@vercel/flags-core: Invalid projectId in connection string"
1134
- );
1135
- }
1136
- this.sourceProjectId = parsed.projectId;
1137
- } else {
1112
+ const parsed = parseSdkKeyFromFlagsConnectionString(
1113
+ sdkKeyOrConnectionString
1114
+ );
1115
+ if (!parsed) {
1138
1116
  throw new Error("@vercel/flags-core: Missing sdkKey");
1139
1117
  }
1118
+ this.sdkKey = parsed;
1140
1119
  }
1141
1120
  }
1142
1121
  async resolveToken() {
@@ -1175,6 +1154,43 @@ function getJitteredWaitMs(baseMs, ratio) {
1175
1154
  return Math.floor(min + Math.random() * span);
1176
1155
  }
1177
1156
 
1157
+ // src/utils/debug-enabled.ts
1158
+ var NAMESPACE = "@vercel/flags-core";
1159
+ var lastValue;
1160
+ var lastEnabled = false;
1161
+ function matches(pattern, namespace) {
1162
+ if (!pattern.includes("*")) {
1163
+ return pattern === namespace;
1164
+ }
1165
+ const expression = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
1166
+ return new RegExp(`^${expression}$`).test(namespace);
1167
+ }
1168
+ function isDebugEnabled() {
1169
+ const value = process.env.DEBUG;
1170
+ if (value === lastValue) {
1171
+ return lastEnabled;
1172
+ }
1173
+ let enabled = false;
1174
+ if (value) {
1175
+ for (const entry of value.split(/[\s,]+/)) {
1176
+ if (!entry) {
1177
+ continue;
1178
+ }
1179
+ if (entry.startsWith("-")) {
1180
+ if (matches(entry.slice(1), NAMESPACE)) {
1181
+ enabled = false;
1182
+ break;
1183
+ }
1184
+ } else if (matches(entry, NAMESPACE)) {
1185
+ enabled = true;
1186
+ }
1187
+ }
1188
+ }
1189
+ lastValue = value;
1190
+ lastEnabled = enabled;
1191
+ return enabled;
1192
+ }
1193
+
1178
1194
  // src/utils/runtime-ingest.ts
1179
1195
  var FLAGS_CONTEXT_SYMBOL = /* @__PURE__ */ Symbol.for("@vercel/flags-context");
1180
1196
  function getRuntimeIngestFor(auth) {
@@ -1194,9 +1210,8 @@ var MAX_RETRIES = 3;
1194
1210
  var MAX_EVENTS_PER_REQUEST = 2e3;
1195
1211
  var EVALUATING_OIDC_TOKEN_HEADER = "X-Vercel-Flags-OIDC-Token";
1196
1212
  var FLUSH_REASON_HEADER = "X-Vercel-Flags-Flush-Reason";
1197
- var isDebugMode = process.env.DEBUG?.includes("@vercel/flags-core");
1198
1213
  var debugLog = (...args) => {
1199
- if (!isDebugMode) return;
1214
+ if (!isDebugEnabled()) return;
1200
1215
  console.log(...args);
1201
1216
  };
1202
1217
  async function getEvaluatingOidcToken(auth) {
@@ -1219,7 +1234,7 @@ async function getIngestHeaders(options, flushReason) {
1219
1234
  "X-Vercel-Env": options.metricEnvironment ?? process.env.VERCEL_ENV
1220
1235
  } : null,
1221
1236
  ...evaluatingOidcToken ? { [EVALUATING_OIDC_TOKEN_HEADER]: evaluatingOidcToken } : null,
1222
- ...isDebugMode ? { "x-vercel-debug-ingest": "1" } : null
1237
+ ...isDebugEnabled() ? { "x-vercel-debug-ingest": "1" } : null
1223
1238
  };
1224
1239
  }
1225
1240
  function getRuntimeIngestHeaders(options, flushReason) {
@@ -1231,7 +1246,7 @@ function getRuntimeIngestHeaders(options, flushReason) {
1231
1246
  ...options.metricEnvironment ?? process.env.VERCEL_ENV ? {
1232
1247
  "X-Vercel-Env": options.metricEnvironment ?? process.env.VERCEL_ENV
1233
1248
  } : null,
1234
- ...isDebugMode ? { "x-vercel-debug-ingest": "1" } : null
1249
+ ...isDebugEnabled() ? { "x-vercel-debug-ingest": "1" } : null
1235
1250
  };
1236
1251
  }
1237
1252
  async function sendIngestEvents(options, events, flushId, flushReason) {
@@ -1647,6 +1662,391 @@ var BundledSource = class {
1647
1662
  }
1648
1663
  };
1649
1664
 
1665
+ // src/controller/debug.ts
1666
+ function debug(clientName, event, details) {
1667
+ if (!isDebugEnabled()) {
1668
+ return;
1669
+ }
1670
+ try {
1671
+ console.debug("@vercel/flags-core", { event, clientName, ...details?.() });
1672
+ } catch {
1673
+ }
1674
+ }
1675
+
1676
+ // src/controller/tagged-data.ts
1677
+ function tagData(data, origin) {
1678
+ return Object.assign(data, { _origin: origin });
1679
+ }
1680
+ function originToMetricsSource(origin) {
1681
+ switch (origin) {
1682
+ case "stream":
1683
+ case "poll":
1684
+ case "provided":
1685
+ return "in-memory";
1686
+ case "fetched":
1687
+ return "remote";
1688
+ case "bundled":
1689
+ return "embedded";
1690
+ }
1691
+ }
1692
+
1693
+ // src/controller/datafile-cache.ts
1694
+ var SOURCE_CONFIRMED = new Error(
1695
+ "Refresh superseded by a source confirmation"
1696
+ );
1697
+ function parseConfigUpdatedAt(value) {
1698
+ if (typeof value === "number") return value;
1699
+ if (typeof value === "string") {
1700
+ const parsed = Number(value);
1701
+ return Number.isNaN(parsed) ? void 0 : parsed;
1702
+ }
1703
+ return void 0;
1704
+ }
1705
+ var DatafileCache = class {
1706
+ constructor(fetch, staleIfErrorMs = Infinity, waitUntil3 = () => {
1707
+ }, clientName) {
1708
+ this.fetch = fetch;
1709
+ this.staleIfErrorMs = staleIfErrorMs;
1710
+ this.waitUntil = waitUntil3;
1711
+ this.clientName = clientName;
1712
+ }
1713
+ fetch;
1714
+ staleIfErrorMs;
1715
+ waitUntil;
1716
+ clientName;
1717
+ data;
1718
+ // Confirmations refresh age without rewriting the datafile's persisted fetch time.
1719
+ freshAt;
1720
+ failure;
1721
+ abortController = new AbortController();
1722
+ fetching;
1723
+ debugState = () => ({
1724
+ projectId: this.data?.projectId,
1725
+ environment: this.data?.environment,
1726
+ hasData: this.hasData,
1727
+ origin: this.data?._origin,
1728
+ revision: this.revision,
1729
+ configUpdatedAt: parseConfigUpdatedAt(this.data?.configUpdatedAt),
1730
+ ageMs: this.ageMs,
1731
+ failed: this.failure !== void 0,
1732
+ failureAgeMs: this.failure ? Date.now() - this.failure.startedAt : void 0,
1733
+ staleIfErrorMs: this.staleIfErrorMs,
1734
+ canServe: this.hasData && this.canServe(),
1735
+ fetching: this.fetching !== void 0
1736
+ });
1737
+ /** Expired data still exists; fallback loading must not bypass its failure policy. */
1738
+ get hasData() {
1739
+ return this.data !== void 0;
1740
+ }
1741
+ /** Retained revisions remain available for reconnecting after serving expires. */
1742
+ get revision() {
1743
+ return this.data?.revision;
1744
+ }
1745
+ /** Time since the latest freshness evidence, if known. */
1746
+ get ageMs() {
1747
+ return this.freshAt === void 0 ? Infinity : Math.max(0, Date.now() - this.freshAt);
1748
+ }
1749
+ /** Records freshness evidence without confirming recovery from a failure. */
1750
+ resetAge() {
1751
+ if (this.data) this.freshAt = Date.now();
1752
+ }
1753
+ /** Freshness checks can inspect retained metadata even after serving expires. */
1754
+ get metadata() {
1755
+ if (!this.data) return void 0;
1756
+ const { projectId, environment, configUpdatedAt, revision } = this.data;
1757
+ return {
1758
+ projectId,
1759
+ environment,
1760
+ configUpdatedAt,
1761
+ revision,
1762
+ ageMs: this.ageMs
1763
+ };
1764
+ }
1765
+ /** Stores initial or fallback data without confirming recovery from a failure. */
1766
+ seed(data) {
1767
+ this.data = data;
1768
+ this.freshAt = typeof data.fetchedAt === "number" && Number.isFinite(data.fetchedAt) && data.fetchedAt >= 0 ? data.fetchedAt : void 0;
1769
+ debug(this.clientName, "cache.seed", this.debugState);
1770
+ }
1771
+ /** Stores data and returns it through the serving boundary. */
1772
+ seedAndRead(data) {
1773
+ this.seed(data);
1774
+ const seeded = this.read();
1775
+ if (!seeded) {
1776
+ throw new Error("@vercel/flags-core: Seeded definitions unavailable");
1777
+ }
1778
+ return seeded;
1779
+ }
1780
+ /**
1781
+ * Accepts a newer source response, or keeps the stored data when the version
1782
+ * guard rejects it. Every successful response proves the source is reachable,
1783
+ * so it renews cache age and clears the failure either way.
1784
+ */
1785
+ updateFromSource(incoming, origin) {
1786
+ if (this.isNewerData(incoming)) {
1787
+ this.data = tagData({ ...incoming, fetchedAt: Date.now() }, origin);
1788
+ this.confirm(origin);
1789
+ debug(this.clientName, "cache.update.accepted", this.debugState);
1790
+ return;
1791
+ }
1792
+ this.confirm(origin);
1793
+ debug(this.clientName, "cache.update.ignored", () => ({
1794
+ ...this.debugState(),
1795
+ incomingOrigin: origin,
1796
+ incomingRevision: incoming.revision,
1797
+ incomingConfigUpdatedAt: parseConfigUpdatedAt(incoming.configUpdatedAt)
1798
+ }));
1799
+ }
1800
+ /** Confirms a same-version message without replacing stored data. */
1801
+ tryConfirm(incoming, version2 = "configUpdatedAt", source = "header") {
1802
+ if (!this.matchesStored(incoming, version2)) {
1803
+ return false;
1804
+ }
1805
+ this.confirm(source);
1806
+ debug(this.clientName, "cache.confirmed", () => ({
1807
+ version: version2,
1808
+ source,
1809
+ ...this.debugState()
1810
+ }));
1811
+ return true;
1812
+ }
1813
+ /** Whether a message refers to the stored entry: finite equal version and same identity. */
1814
+ matchesStored(incoming, version2) {
1815
+ if (!this.data) {
1816
+ return false;
1817
+ }
1818
+ const currentTs = version2 === "revision" ? this.data.revision : parseConfigUpdatedAt(this.data.configUpdatedAt);
1819
+ const incomingTs = version2 === "revision" ? incoming.revision : parseConfigUpdatedAt(incoming.configUpdatedAt);
1820
+ return Number.isFinite(currentTs) && Number.isFinite(incomingTs) && currentTs === incomingTs && this.data.projectId === incoming.projectId && this.data.environment === incoming.environment;
1821
+ }
1822
+ /** Renews freshness and retires HTTP work superseded by stream evidence. */
1823
+ confirm(source = "header") {
1824
+ const recovered = this.failure !== void 0;
1825
+ this.resetAge();
1826
+ this.failure = void 0;
1827
+ if (recovered) {
1828
+ debug(this.clientName, "cache.recovered", this.debugState);
1829
+ }
1830
+ if (source === "stream" && this.fetching) {
1831
+ debug(this.clientName, "cache.fetch.cancel", () => ({
1832
+ reason: "source-confirmed",
1833
+ ...this.debugState()
1834
+ }));
1835
+ this.abortController.abort(SOURCE_CONFIRMED);
1836
+ this.abortController = new AbortController();
1837
+ this.fetching = void 0;
1838
+ }
1839
+ }
1840
+ /** Preserves existing acceptance, including missing or unparseable versions. */
1841
+ isNewerData(incoming) {
1842
+ if (!this.data) return true;
1843
+ const currentTs = parseConfigUpdatedAt(this.data.configUpdatedAt);
1844
+ const incomingTs = parseConfigUpdatedAt(incoming.configUpdatedAt);
1845
+ if (currentTs === void 0 || incomingTs === void 0) {
1846
+ return true;
1847
+ }
1848
+ return incomingTs > currentTs;
1849
+ }
1850
+ fail(error) {
1851
+ const repeated = this.failure !== void 0;
1852
+ this.failure ??= { error, startedAt: Date.now() };
1853
+ debug(this.clientName, "cache.failure", () => ({
1854
+ repeated,
1855
+ ...this.debugState()
1856
+ }));
1857
+ }
1858
+ canServe() {
1859
+ if (!this.failure || this.staleIfErrorMs === Infinity) return true;
1860
+ return this.staleIfErrorMs > 0 && Date.now() - this.failure.startedAt <= this.staleIfErrorMs;
1861
+ }
1862
+ /** The serving boundary for both snapshot and policy-driven reads. */
1863
+ read() {
1864
+ if (!this.data) return void 0;
1865
+ if (!this.canServe()) {
1866
+ debug(this.clientName, "cache.stale-if-error.expired", this.debugState);
1867
+ throw this.failure.error;
1868
+ }
1869
+ return this.data;
1870
+ }
1871
+ async resolve(policy) {
1872
+ const metadata = this.metadata;
1873
+ if (metadata) {
1874
+ const { status, confirmed } = policy.assess(metadata);
1875
+ if (status === "error") {
1876
+ debug(this.clientName, "cache.source-error", this.debugState);
1877
+ return {
1878
+ data: this.canServe() ? this.read() : void 0,
1879
+ status: "STALE",
1880
+ hasError: true
1881
+ };
1882
+ }
1883
+ if (confirmed) this.confirm();
1884
+ debug(this.clientName, "cache.freshness", () => ({
1885
+ status,
1886
+ confirmed: confirmed === true,
1887
+ ...this.debugState()
1888
+ }));
1889
+ if (status === "fresh" || status === "unknown") {
1890
+ return {
1891
+ data: this.read(),
1892
+ status: status === "fresh" && !this.failure ? "HIT" : "STALE"
1893
+ };
1894
+ }
1895
+ if (this.failure) {
1896
+ if (!policy.retryOnFailure) {
1897
+ debug(this.clientName, "cache.stale-if-error", () => ({
1898
+ ...this.debugState(),
1899
+ recovery: "scheduled-source-update"
1900
+ }));
1901
+ return { data: this.read(), status: "STALE" };
1902
+ }
1903
+ if (this.canServe()) {
1904
+ const stale = this.read();
1905
+ debug(this.clientName, "cache.refresh.background", () => ({
1906
+ ...this.debugState(),
1907
+ reason: "retry-after-failure"
1908
+ }));
1909
+ this.fetchInBackground();
1910
+ return { data: stale, status: "STALE" };
1911
+ }
1912
+ }
1913
+ if (status === "stale" && this.canServe()) {
1914
+ const stale = this.read();
1915
+ if (!policy.revalidate) {
1916
+ debug(this.clientName, "cache.refresh.background", () => ({
1917
+ ...this.debugState(),
1918
+ reason: "stale"
1919
+ }));
1920
+ this.fetchInBackground();
1921
+ }
1922
+ return { data: stale, status: "STALE" };
1923
+ }
1924
+ if (policy.revalidate) {
1925
+ return this.resolveThroughSource(policy);
1926
+ }
1927
+ }
1928
+ debug(this.clientName, "cache.refresh.blocking", () => ({
1929
+ ...this.debugState(),
1930
+ reason: !metadata ? "empty-cache" : "expired"
1931
+ }));
1932
+ const { promise, signal } = this.startFetch("fetched");
1933
+ try {
1934
+ await promise;
1935
+ signal.throwIfAborted();
1936
+ } catch (error) {
1937
+ if (signal.aborted) {
1938
+ if (signal.reason === SOURCE_CONFIRMED && this.data) {
1939
+ debug(this.clientName, "cache.refresh.superseded", this.debugState);
1940
+ return { data: this.read(), status: "HIT" };
1941
+ }
1942
+ throw error;
1943
+ }
1944
+ const stale = this.read();
1945
+ if (!stale) {
1946
+ throw error;
1947
+ }
1948
+ debug(this.clientName, "cache.stale-if-error", () => ({
1949
+ ...this.debugState(),
1950
+ recovery: "refresh-failed"
1951
+ }));
1952
+ return { data: stale, status: "STALE" };
1953
+ }
1954
+ if (!metadata && this.metadata) {
1955
+ const { confirmed } = policy.assess(this.metadata);
1956
+ if (confirmed) this.confirm();
1957
+ }
1958
+ const data = this.read();
1959
+ if (!data) {
1960
+ throw new Error("@vercel/flags-core: Fetch returned no definitions");
1961
+ }
1962
+ return { data, status: "MISS" };
1963
+ }
1964
+ /**
1965
+ * Waits for the live source to confirm or replace the cache, then serves
1966
+ * whatever it left behind. A failed or silent source leaves the failure
1967
+ * policy to decide; the source's own fallback path takes over from there.
1968
+ */
1969
+ async resolveThroughSource(policy) {
1970
+ await policy.revalidate?.().catch(() => {
1971
+ });
1972
+ const data = this.read();
1973
+ const metadata = this.metadata;
1974
+ if (!data || !metadata) {
1975
+ throw new Error("@vercel/flags-core: Definitions unavailable");
1976
+ }
1977
+ const { status } = policy.assess(metadata);
1978
+ return {
1979
+ data,
1980
+ status: status === "fresh" && !this.failure ? "HIT" : "STALE"
1981
+ };
1982
+ }
1983
+ /** Runs the one shared datafile refresh used by reads and polling. */
1984
+ refresh(origin = "fetched") {
1985
+ return this.startFetch(origin).promise;
1986
+ }
1987
+ startFetch(origin) {
1988
+ const { signal } = this.abortController;
1989
+ if (this.fetching) {
1990
+ debug(this.clientName, "cache.fetch.shared", () => ({
1991
+ requestedOrigin: origin,
1992
+ ...this.debugState()
1993
+ }));
1994
+ return { promise: this.fetching, signal };
1995
+ }
1996
+ debug(this.clientName, "cache.fetch.start", () => ({
1997
+ requestedOrigin: origin,
1998
+ ...this.debugState()
1999
+ }));
2000
+ const promise = Promise.resolve().then(() => {
2001
+ signal.throwIfAborted();
2002
+ return this.fetch(signal);
2003
+ }).then((data) => {
2004
+ signal.throwIfAborted();
2005
+ this.updateFromSource(data, origin);
2006
+ debug(this.clientName, "cache.fetch.applied", this.debugState);
2007
+ }).catch((error) => {
2008
+ if (signal.aborted) {
2009
+ debug(this.clientName, "cache.fetch.aborted", () => ({
2010
+ reason: signal.reason === SOURCE_CONFIRMED ? "source-confirmed" : "cache-cleared"
2011
+ }));
2012
+ }
2013
+ signal.throwIfAborted();
2014
+ const err = error instanceof Error ? error : new Error("Unknown fetch error");
2015
+ this.fail(err);
2016
+ debug(this.clientName, "cache.fetch.failed", this.debugState);
2017
+ throw err;
2018
+ }).finally(() => {
2019
+ if (this.abortController.signal === signal) {
2020
+ this.fetching = void 0;
2021
+ }
2022
+ });
2023
+ this.fetching = promise;
2024
+ return { promise, signal };
2025
+ }
2026
+ fetchInBackground() {
2027
+ const { promise, signal } = this.startFetch("fetched");
2028
+ const background = promise.catch((error) => {
2029
+ if (!signal.aborted) {
2030
+ console.error("@vercel/flags-core: Revalidation failed:", error);
2031
+ }
2032
+ });
2033
+ try {
2034
+ this.waitUntil(background);
2035
+ } catch {
2036
+ }
2037
+ }
2038
+ /** Resets storage, age, and the failure deadline so a restarted client begins clean. */
2039
+ clear() {
2040
+ debug(this.clientName, "cache.clear", this.debugState);
2041
+ this.abortController.abort();
2042
+ this.abortController = new AbortController();
2043
+ this.fetching = void 0;
2044
+ this.data = void 0;
2045
+ this.freshAt = void 0;
2046
+ this.failure = void 0;
2047
+ }
2048
+ };
2049
+
1650
2050
  // src/controller/fetch-datafile.ts
1651
2051
  var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
1652
2052
  var DEFAULT_MAX_ATTEMPTS = 3;
@@ -1666,6 +2066,10 @@ async function fetchDatafile(options) {
1666
2066
  if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
1667
2067
  throw new Error("maxAttempts must be a positive integer");
1668
2068
  }
2069
+ debug(options.clientName, "datafile.fetch.start", () => ({
2070
+ maxAttempts,
2071
+ timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
2072
+ }));
1669
2073
  const controller = new AbortController();
1670
2074
  const { signal } = controller;
1671
2075
  let rejectAbort;
@@ -1676,15 +2080,24 @@ async function fetchDatafile(options) {
1676
2080
  signal.addEventListener("abort", onAbort, { once: true });
1677
2081
  const onExternalAbort = () => controller.abort(options.signal?.reason);
1678
2082
  options.signal?.addEventListener("abort", onExternalAbort, { once: true });
1679
- const timeoutId = setTimeout(
1680
- () => controller.abort(
2083
+ const timeoutId = setTimeout(() => {
2084
+ debug(options.clientName, "datafile.fetch.timeout", () => ({
2085
+ timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
2086
+ }));
2087
+ controller.abort(
1681
2088
  new Error("@vercel/flags-core: Datafile fetch deadline exceeded")
1682
- ),
1683
- DEFAULT_FETCH_TIMEOUT_MS
1684
- );
2089
+ );
2090
+ }, DEFAULT_FETCH_TIMEOUT_MS);
1685
2091
  let delay;
1686
- const fetchAttempt = async () => {
1687
- const token = await options.auth.resolveToken();
2092
+ const fetchAttempt = async (attempt) => {
2093
+ debug(options.clientName, "datafile.fetch.attempt", () => ({
2094
+ attempt,
2095
+ maxAttempts
2096
+ }));
2097
+ const token = await options.auth.resolveToken().catch((error) => {
2098
+ debug(options.clientName, "datafile.auth.failed", () => ({ attempt }));
2099
+ throw error;
2100
+ });
1688
2101
  signal.throwIfAborted();
1689
2102
  const res = await options.fetch(`${options.host}/v1/datafile`, {
1690
2103
  headers: {
@@ -1695,6 +2108,10 @@ async function fetchDatafile(options) {
1695
2108
  signal
1696
2109
  });
1697
2110
  signal.throwIfAborted();
2111
+ debug(options.clientName, "datafile.fetch.response", () => ({
2112
+ attempt,
2113
+ status: res.status
2114
+ }));
1698
2115
  if (!res.ok) {
1699
2116
  void res.body?.cancel().catch(() => {
1700
2117
  });
@@ -1705,6 +2122,12 @@ async function fetchDatafile(options) {
1705
2122
  }
1706
2123
  const data = await res.json();
1707
2124
  signal.throwIfAborted();
2125
+ debug(options.clientName, "datafile.fetch.complete", () => ({
2126
+ attempt,
2127
+ projectId: data.projectId,
2128
+ revision: data.revision,
2129
+ configUpdatedAt: Number(data.configUpdatedAt)
2130
+ }));
1708
2131
  return data;
1709
2132
  };
1710
2133
  try {
@@ -1719,14 +2142,31 @@ async function fetchDatafile(options) {
1719
2142
  }
1720
2143
  signal.throwIfAborted();
1721
2144
  try {
1722
- return await Promise.race([fetchAttempt(), aborted]);
2145
+ return await Promise.race([fetchAttempt(attempt + 1), aborted]);
1723
2146
  } catch (error) {
1724
2147
  signal.throwIfAborted();
1725
2148
  if (attempt === maxAttempts - 1 || error instanceof DatafileHttpError && !error.retryable) {
1726
2149
  throw error instanceof Error ? error : new Error("Unknown fetch error");
1727
2150
  }
2151
+ debug(options.clientName, "datafile.fetch.retry", () => ({
2152
+ nextAttempt: attempt + 2,
2153
+ delayMs: 100 * 2 ** attempt,
2154
+ status: error instanceof DatafileHttpError ? error.status : void 0
2155
+ }));
1728
2156
  }
1729
2157
  }
2158
+ } catch (error) {
2159
+ if (signal.aborted) {
2160
+ debug(options.clientName, "datafile.fetch.aborted", () => ({
2161
+ // The internal deadline aborts too; only the caller's signal is external.
2162
+ reason: options.signal?.aborted ? "external" : "timeout"
2163
+ }));
2164
+ } else {
2165
+ debug(options.clientName, "datafile.fetch.failed", () => ({
2166
+ status: error instanceof DatafileHttpError ? error.status : void 0
2167
+ }));
2168
+ }
2169
+ throw error;
1730
2170
  } finally {
1731
2171
  clearTimeout(timeoutId);
1732
2172
  clearTimeout(delay);
@@ -1735,13 +2175,79 @@ async function fetchDatafile(options) {
1735
2175
  }
1736
2176
  }
1737
2177
 
2178
+ // src/controller/header-source.ts
2179
+ var HeaderSource = class {
2180
+ constructor(options) {
2181
+ this.options = options;
2182
+ }
2183
+ options;
2184
+ highestObserved = 0;
2185
+ getVersionHeader() {
2186
+ const { headers } = getRequestContext();
2187
+ return headers?.["x-vercel-flags-config-versions"] ?? headers?.["flags-config-versions"];
2188
+ }
2189
+ /** Capture the header now so a shared fetch cannot switch the request being assessed. */
2190
+ getAssessment() {
2191
+ const header = this.getVersionHeader();
2192
+ return (data) => {
2193
+ const headerTs = this.getUpdatedAtHeader(data.projectId, header);
2194
+ debug(this.options.clientName, "header.observed", () => ({
2195
+ projectId: data.projectId,
2196
+ hasHeader: Boolean(header),
2197
+ headerTimestamp: headerTs,
2198
+ configUpdatedAt: Number(data.configUpdatedAt),
2199
+ previousHighestObserved: this.highestObserved,
2200
+ staleWhileRevalidateMs: this.options.staleWhileRevalidateMs
2201
+ }));
2202
+ if (headerTs === void 0) {
2203
+ return { status: "error" };
2204
+ }
2205
+ const currentTs = Number(data.configUpdatedAt);
2206
+ this.highestObserved = Math.max(this.highestObserved, headerTs);
2207
+ if (!Number.isFinite(currentTs) || currentTs <= 0) {
2208
+ return { status: "error" };
2209
+ }
2210
+ if (headerTs <= currentTs) {
2211
+ return {
2212
+ status: "fresh",
2213
+ confirmed: headerTs === currentTs && headerTs === this.highestObserved
2214
+ };
2215
+ }
2216
+ const { staleWhileRevalidateMs } = this.options;
2217
+ return {
2218
+ status: staleWhileRevalidateMs > 0 && data.ageMs <= staleWhileRevalidateMs ? "stale" : "expired"
2219
+ };
2220
+ };
2221
+ }
2222
+ getUpdatedAtHeader(projectId, header) {
2223
+ if (!header) return;
2224
+ const prefix = `flags_${projectId}=`;
2225
+ const value = header.split(";").map((part) => part.trim()).find((part) => part.startsWith(prefix))?.slice(prefix.length);
2226
+ const timestamp = Number(value);
2227
+ return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : void 0;
2228
+ }
2229
+ isEnabled() {
2230
+ return this.options.vercel && (this.options.stream.enabled || this.options.polling.enabled);
2231
+ }
2232
+ stop() {
2233
+ this.highestObserved = 0;
2234
+ }
2235
+ };
2236
+
1738
2237
  // src/controller/normalized-options.ts
1739
2238
  import { waitUntil as defaultWaitUntil2 } from "@vercel/functions";
1740
2239
  var DEFAULT_STREAM_INIT_TIMEOUT_MS = 3e3;
1741
2240
  var DEFAULT_POLLING_INTERVAL_MS = 3e4;
1742
2241
  var MIN_POLLING_INTERVAL_MS = 3e4;
1743
2242
  var DEFAULT_POLLING_INIT_TIMEOUT_MS = 3e3;
2243
+ var DEFAULT_STALE_WHILE_REVALIDATE = 10;
1744
2244
  function normalizeOptions(options) {
2245
+ const staleIfError = options.staleIfError ?? Infinity;
2246
+ if (typeof staleIfError !== "number" || !(staleIfError >= 0)) {
2247
+ throw new Error(
2248
+ "@vercel/flags-core: staleIfError must be a nonnegative number of seconds or Infinity."
2249
+ );
2250
+ }
1745
2251
  const autoDetectedBuildStep = process.env.CI === "1" || process.env.NEXT_PHASE === "phase-production-build";
1746
2252
  const buildStep = options.buildStep ?? autoDetectedBuildStep;
1747
2253
  let stream;
@@ -1773,11 +2279,20 @@ function normalizeOptions(options) {
1773
2279
  initTimeoutMs: options.polling.initTimeoutMs
1774
2280
  };
1775
2281
  }
2282
+ const staleWhileRevalidate = options.staleWhileRevalidate ?? DEFAULT_STALE_WHILE_REVALIDATE;
2283
+ if (!Number.isFinite(staleWhileRevalidate) || staleWhileRevalidate < 0) {
2284
+ throw new Error(
2285
+ "@vercel/flags-core: staleWhileRevalidate must be a finite, non-negative number of seconds."
2286
+ );
2287
+ }
1776
2288
  return {
1777
2289
  auth: options.auth,
1778
2290
  datafile: options.datafile,
1779
2291
  stream,
1780
2292
  polling,
2293
+ vercel: options.vercel ?? process.env.VERCEL === "1",
2294
+ staleWhileRevalidateMs: staleWhileRevalidate * 1e3,
2295
+ staleIfErrorMs: staleIfError * 1e3,
1781
2296
  buildStep,
1782
2297
  fetch: options.fetch ?? globalThis.fetch,
1783
2298
  waitUntil: options.waitUntil ?? defaultWaitUntil2,
@@ -1822,29 +2337,63 @@ var PollingSource = class extends TypedEmitter {
1822
2337
  config;
1823
2338
  intervalId;
1824
2339
  abortController;
2340
+ polling;
1825
2341
  constructor(config) {
1826
2342
  super();
1827
2343
  this.config = config;
1828
2344
  }
2345
+ /** Allow the scheduled poll its entire fetch deadline before revalidating. */
2346
+ get staleAfterMs() {
2347
+ return this.config.polling.intervalMs + DEFAULT_FETCH_TIMEOUT_MS;
2348
+ }
2349
+ /** Age after which reads block on a refresh. */
2350
+ get expiresAfterMs() {
2351
+ return this.staleAfterMs + this.config.staleWhileRevalidateMs;
2352
+ }
2353
+ assess = ({ ageMs }) => {
2354
+ if (ageMs === Infinity) {
2355
+ return { status: "unknown" };
2356
+ }
2357
+ if (ageMs <= this.staleAfterMs) {
2358
+ return { status: "fresh" };
2359
+ }
2360
+ if (ageMs <= this.expiresAfterMs) {
2361
+ return { status: "stale" };
2362
+ }
2363
+ return { status: "expired" };
2364
+ };
1829
2365
  /**
1830
2366
  * Perform a single poll request.
1831
- * Emits 'data' on success, 'error' on failure.
2367
+ * Updates the cache on success; emits 'error' and rejects on failure.
1832
2368
  */
1833
2369
  async poll() {
2370
+ if (this.polling) {
2371
+ debug(this.config.clientName, "poll.shared");
2372
+ return this.polling;
2373
+ }
1834
2374
  if (this.abortController?.signal.aborted) return;
1835
2375
  this.abortController ??= new AbortController();
1836
2376
  const controller = this.abortController;
1837
- try {
1838
- const data = await fetchDatafile({
1839
- ...this.config,
1840
- signal: controller.signal
1841
- });
1842
- this.emit("data", data);
1843
- } catch (error) {
1844
- controller.signal.throwIfAborted();
1845
- const err = error instanceof Error ? error : new Error("Unknown poll error");
1846
- this.emit("error", err);
1847
- }
2377
+ debug(this.config.clientName, "poll.start");
2378
+ this.polling = (async () => {
2379
+ try {
2380
+ await this.config.refresh();
2381
+ controller.signal.throwIfAborted();
2382
+ debug(this.config.clientName, "poll.complete");
2383
+ } catch (error) {
2384
+ debug(
2385
+ this.config.clientName,
2386
+ controller.signal.aborted ? "poll.aborted" : "poll.failed"
2387
+ );
2388
+ controller.signal.throwIfAborted();
2389
+ const err = error instanceof Error ? error : new Error("Unknown poll error");
2390
+ this.emit("error", err);
2391
+ throw err;
2392
+ }
2393
+ })().finally(() => {
2394
+ if (this.abortController === controller) this.polling = void 0;
2395
+ });
2396
+ return this.polling;
1848
2397
  }
1849
2398
  /**
1850
2399
  * Start interval-based polling.
@@ -1853,6 +2402,11 @@ var PollingSource = class extends TypedEmitter {
1853
2402
  */
1854
2403
  startInterval() {
1855
2404
  if (this.intervalId) return;
2405
+ debug(this.config.clientName, "poll.interval.start", () => ({
2406
+ intervalMs: this.config.polling.intervalMs,
2407
+ staleAfterMs: this.config.polling.intervalMs + DEFAULT_FETCH_TIMEOUT_MS,
2408
+ expiresAfterMs: 2 * this.config.polling.intervalMs + DEFAULT_FETCH_TIMEOUT_MS
2409
+ }));
1856
2410
  this.intervalId = setInterval(
1857
2411
  () => void this.poll().catch(() => {
1858
2412
  }),
@@ -1863,12 +2417,18 @@ var PollingSource = class extends TypedEmitter {
1863
2417
  * Stop interval-based polling.
1864
2418
  */
1865
2419
  stop() {
2420
+ if (this.intervalId || this.polling) {
2421
+ debug(this.config.clientName, "poll.stop", () => ({
2422
+ pendingPoll: this.polling !== void 0
2423
+ }));
2424
+ }
1866
2425
  if (this.intervalId) {
1867
2426
  clearInterval(this.intervalId);
1868
2427
  this.intervalId = void 0;
1869
2428
  }
1870
2429
  this.abortController?.abort();
1871
2430
  this.abortController = void 0;
2431
+ this.polling = void 0;
1872
2432
  }
1873
2433
  };
1874
2434
 
@@ -1886,7 +2446,9 @@ function sleep(ms) {
1886
2446
  var MAX_RETRY_COUNT = 15;
1887
2447
  var BASE_RETRY_DELAY_MS = 1e3;
1888
2448
  var MAX_RETRY_DELAY_MS = 6e4;
1889
- var PING_TIMEOUT_MS = 9e4;
2449
+ var PING_MS = 3e4;
2450
+ var PING_TIMEOUT_MS = PING_MS * 3;
2451
+ var PING_TIMEOUT = new Error("stream: ping timeout");
1890
2452
  function backoff(retryCount) {
1891
2453
  if (retryCount === 1) return 0;
1892
2454
  const delay = Math.min(
@@ -1909,9 +2471,15 @@ var TokenResolutionError = class extends Error {
1909
2471
  };
1910
2472
  async function connectStream(config, callbacks) {
1911
2473
  const { host, abortController, fetch: fetchFn = globalThis.fetch } = config;
1912
- const { onDatafile, onPrimed, onDisconnect } = callbacks;
2474
+ const { onDatafile, onPrimed, onPing, onDisconnect, onError, onConnection } = callbacks;
1913
2475
  let retryCount = 0;
1914
2476
  let lastAttemptTime = 0;
2477
+ const reportError = (error) => {
2478
+ if (abortController.signal.aborted) return;
2479
+ onError?.(
2480
+ error instanceof Error ? error : new Error("Unknown stream error", { cause: error })
2481
+ );
2482
+ };
1915
2483
  let resolveInit;
1916
2484
  let rejectInit;
1917
2485
  const initPromise = new Promise((resolve, reject) => {
@@ -1923,6 +2491,9 @@ async function connectStream(config, callbacks) {
1923
2491
  let lastError;
1924
2492
  while (!abortController.signal.aborted) {
1925
2493
  if (retryCount > MAX_RETRY_COUNT) {
2494
+ debug(config.clientName, "stream.retries.exhausted", () => ({
2495
+ retryCount
2496
+ }));
1926
2497
  console.error(
1927
2498
  "@vercel/flags-core: Max retry count exceeded",
1928
2499
  lastError ?? "stream closed repeatedly without an error"
@@ -1932,6 +2503,8 @@ async function connectStream(config, callbacks) {
1932
2503
  new Error("stream: max retry count exceeded before receiving data")
1933
2504
  );
1934
2505
  }
2506
+ reportError(new Error("stream: max retry count exceeded"));
2507
+ onDisconnect?.();
1935
2508
  abortController.abort();
1936
2509
  break;
1937
2510
  }
@@ -1940,20 +2513,24 @@ async function connectStream(config, callbacks) {
1940
2513
  abortController.signal.addEventListener("abort", onMainAbort, {
1941
2514
  once: true
1942
2515
  });
2516
+ onConnection?.(() => connectionAbort.abort(PING_TIMEOUT));
1943
2517
  let pingTimeoutId;
1944
- let responseBody;
1945
2518
  const resetPingTimeout = () => {
1946
2519
  if (pingTimeoutId !== void 0) clearTimeout(pingTimeoutId);
1947
2520
  if (!initialDataReceived) return;
1948
2521
  pingTimeoutId = setTimeout(() => {
1949
- responseBody?.cancel().catch(() => {
1950
- });
1951
- connectionAbort.abort();
2522
+ debug(config.clientName, "stream.ping.timeout", () => ({
2523
+ timeoutMs: PING_TIMEOUT_MS
2524
+ }));
2525
+ lastError = PING_TIMEOUT;
2526
+ connectionAbort.abort(PING_TIMEOUT);
1952
2527
  }, PING_TIMEOUT_MS);
1953
2528
  };
1954
2529
  try {
2530
+ debug(config.clientName, "stream.connect", () => ({ retryCount }));
1955
2531
  lastAttemptTime = Date.now();
1956
2532
  const token = await config.resolveToken().catch((error) => {
2533
+ debug(config.clientName, "stream.auth.failed");
1957
2534
  throw new TokenResolutionError(error);
1958
2535
  });
1959
2536
  const headers = {
@@ -1976,20 +2553,26 @@ async function connectStream(config, callbacks) {
1976
2553
  headers,
1977
2554
  signal: connectionAbort.signal
1978
2555
  });
1979
- if (!response.ok) {
1980
- if (response.status === 401) {
1981
- if (!initialDataReceived) {
1982
- rejectInit(new UnauthorizedError(config.sourceProjectId));
1983
- }
1984
- abortController.abort();
1985
- break;
2556
+ debug(config.clientName, "stream.response", () => ({
2557
+ status: response.status,
2558
+ revision
2559
+ }));
2560
+ if (!response.ok && response.status === 401) {
2561
+ const error = new UnauthorizedError(config.sourceProjectId);
2562
+ reportError(error);
2563
+ if (!initialDataReceived) {
2564
+ rejectInit(error);
1986
2565
  }
2566
+ onDisconnect?.();
2567
+ abortController.abort();
2568
+ break;
2569
+ }
2570
+ if (!response.ok) {
1987
2571
  throw new Error(`stream was not ok: ${response.status}`);
1988
2572
  }
1989
2573
  if (!response.body) {
1990
2574
  throw new Error("stream body was not present");
1991
2575
  }
1992
- responseBody = response.body;
1993
2576
  const reader = response.body.getReader();
1994
2577
  const decoder = new TextDecoder();
1995
2578
  const bufferChunks = [];
@@ -2000,10 +2583,13 @@ async function connectStream(config, callbacks) {
2000
2583
  connectionAbort.signal.addEventListener("abort", onConnectionAbort, {
2001
2584
  once: true
2002
2585
  });
2586
+ resetPingTimeout();
2003
2587
  try {
2004
2588
  while (true) {
2005
2589
  const { done, value: chunk } = await reader.read();
2006
- if (done || abortController.signal.aborted) break;
2590
+ if (done || connectionAbort.signal.aborted) {
2591
+ break;
2592
+ }
2007
2593
  bufferChunks.push(decoder.decode(chunk, { stream: true }));
2008
2594
  const combined = bufferChunks.join("");
2009
2595
  bufferChunks.length = 0;
@@ -2039,6 +2625,7 @@ async function connectStream(config, callbacks) {
2039
2625
  resetPingTimeout();
2040
2626
  }
2041
2627
  if (message.type === "ping" && initialDataReceived) {
2628
+ onPing?.();
2042
2629
  retryCount = 0;
2043
2630
  resetPingTimeout();
2044
2631
  }
@@ -2053,11 +2640,19 @@ async function connectStream(config, callbacks) {
2053
2640
  clearTimeout(pingTimeoutId);
2054
2641
  abortController.signal.removeEventListener("abort", onMainAbort);
2055
2642
  if (!abortController.signal.aborted) {
2056
- onDisconnect?.();
2643
+ if (connectionAbort.signal.reason !== PING_TIMEOUT) {
2644
+ onDisconnect?.();
2645
+ }
2057
2646
  retryCount++;
2058
2647
  const elapsed = Date.now() - lastAttemptTime;
2059
2648
  const minGap = Math.max(0, BASE_RETRY_DELAY_MS - elapsed);
2060
- await sleep(Math.max(backoff(retryCount), minGap));
2649
+ const delayMs = Math.max(backoff(retryCount), minGap);
2650
+ debug(config.clientName, "stream.reconnect", () => ({
2651
+ retryCount,
2652
+ delayMs,
2653
+ reason: connectionAbort.signal.reason === PING_TIMEOUT ? "ping-timeout" : "connection-closed"
2654
+ }));
2655
+ await sleep(delayMs);
2061
2656
  continue;
2062
2657
  }
2063
2658
  } catch (error) {
@@ -2066,19 +2661,28 @@ async function connectStream(config, callbacks) {
2066
2661
  if (abortController.signal.aborted) {
2067
2662
  break;
2068
2663
  }
2664
+ if (connectionAbort.signal.reason !== PING_TIMEOUT) {
2665
+ reportError(error);
2666
+ }
2069
2667
  if (error instanceof TokenResolutionError && !initialDataReceived) {
2070
2668
  rejectInit(error.cause);
2071
2669
  abortController.abort();
2072
2670
  break;
2073
2671
  }
2074
- if (!connectionAbort.signal.aborted) {
2672
+ if (connectionAbort.signal.reason !== PING_TIMEOUT) {
2075
2673
  lastError = error;
2674
+ onDisconnect?.();
2076
2675
  }
2077
- onDisconnect?.();
2078
2676
  retryCount++;
2079
2677
  const elapsed = Date.now() - lastAttemptTime;
2080
2678
  const minGap = Math.max(0, BASE_RETRY_DELAY_MS - elapsed);
2081
- await sleep(Math.max(backoff(retryCount), minGap));
2679
+ const delayMs = Math.max(backoff(retryCount), minGap);
2680
+ debug(config.clientName, "stream.reconnect", () => ({
2681
+ retryCount,
2682
+ delayMs,
2683
+ reason: connectionAbort.signal.reason === PING_TIMEOUT ? "ping-timeout" : "connection-error"
2684
+ }));
2685
+ await sleep(delayMs);
2082
2686
  }
2083
2687
  }
2084
2688
  if (!initialDataReceived) {
@@ -2089,16 +2693,105 @@ async function connectStream(config, callbacks) {
2089
2693
  }
2090
2694
 
2091
2695
  // src/controller/stream-source.ts
2696
+ var STREAM_FRESH_MS = PING_MS * 2;
2092
2697
  var StreamSource = class extends TypedEmitter {
2093
2698
  options;
2094
2699
  revision;
2095
2700
  abortController;
2096
2701
  promise;
2702
+ reconnect;
2703
+ connectionStartedAt = 0;
2704
+ revalidation;
2097
2705
  constructor(options, revision) {
2098
2706
  super();
2099
2707
  this.options = options;
2100
2708
  this.revision = revision;
2101
2709
  }
2710
+ /** Age after which reads revalidate in the background. */
2711
+ get staleAfterMs() {
2712
+ return STREAM_FRESH_MS;
2713
+ }
2714
+ /** Age after which reads block on a refresh. */
2715
+ get expiresAfterMs() {
2716
+ return STREAM_FRESH_MS + this.options.staleWhileRevalidateMs;
2717
+ }
2718
+ /** The connection loop is connecting or connected; false once it gave up or was stopped. */
2719
+ get active() {
2720
+ return this.abortController !== void 0;
2721
+ }
2722
+ /**
2723
+ * Drops a connection that has been silent beyond the fresh window and
2724
+ * resolves once the stream delivers any message, which confirms or replaces
2725
+ * the cache through the usual events. A younger connection, such as the
2726
+ * replacement a ping timeout just opened, is kept and awaited instead.
2727
+ * Rejects when the stream disconnects, gives up, is stopped, or stays silent
2728
+ * for the fetch deadline. Concurrent callers share one wait.
2729
+ */
2730
+ revalidate() {
2731
+ if (this.revalidation) {
2732
+ return this.revalidation.promise;
2733
+ }
2734
+ if (!this.active) {
2735
+ return Promise.reject(new Error("stream: not active"));
2736
+ }
2737
+ let cleanup = () => {
2738
+ };
2739
+ let fail = (_error) => {
2740
+ };
2741
+ const promise = new Promise((resolve, reject) => {
2742
+ const confirmed = () => {
2743
+ cleanup();
2744
+ resolve();
2745
+ };
2746
+ fail = (error) => {
2747
+ cleanup();
2748
+ reject(error);
2749
+ };
2750
+ const interrupted = () => {
2751
+ fail(new Error("stream: revalidation interrupted"));
2752
+ };
2753
+ const timer = setTimeout(() => {
2754
+ fail(new Error("stream: revalidation timed out"));
2755
+ }, DEFAULT_FETCH_TIMEOUT_MS);
2756
+ cleanup = () => {
2757
+ clearTimeout(timer);
2758
+ this.off("data", confirmed);
2759
+ this.off("primed", confirmed);
2760
+ this.off("ping", confirmed);
2761
+ this.off("disconnected", interrupted);
2762
+ this.off("exhausted", interrupted);
2763
+ };
2764
+ this.on("data", confirmed);
2765
+ this.on("primed", confirmed);
2766
+ this.on("ping", confirmed);
2767
+ this.on("disconnected", interrupted);
2768
+ this.on("exhausted", interrupted);
2769
+ });
2770
+ const revalidation = { promise, fail };
2771
+ this.revalidation = revalidation;
2772
+ void promise.catch(() => {
2773
+ }).finally(() => {
2774
+ if (this.revalidation === revalidation) {
2775
+ this.revalidation = void 0;
2776
+ }
2777
+ });
2778
+ if (Date.now() - this.connectionStartedAt >= STREAM_FRESH_MS) {
2779
+ this.reconnect?.();
2780
+ }
2781
+ return promise;
2782
+ }
2783
+ assess = ({ ageMs }) => {
2784
+ if (ageMs === Infinity) {
2785
+ return { status: "unknown" };
2786
+ }
2787
+ if (ageMs <= this.staleAfterMs) {
2788
+ return { status: "fresh" };
2789
+ }
2790
+ if (ageMs <= this.expiresAfterMs) {
2791
+ return { status: "stale" };
2792
+ }
2793
+ return { status: "expired" };
2794
+ };
2102
2795
  /**
2103
2796
  * Start the stream connection.
2104
2797
  * Returns a promise that resolves when the first datafile or primed message arrives.
@@ -2106,6 +2799,11 @@ var StreamSource = class extends TypedEmitter {
2106
2799
  */
2107
2800
  start() {
2108
2801
  if (this.promise) return this.promise;
2802
+ debug(this.options.clientName, "stream.start", () => ({
2803
+ revision: this.revision(),
2804
+ staleAfterMs: this.staleAfterMs,
2805
+ expiresAfterMs: this.expiresAfterMs
2806
+ }));
2109
2807
  const abortController = new AbortController();
2110
2808
  this.abortController = abortController;
2111
2809
  abortController.signal.addEventListener(
@@ -2114,6 +2812,7 @@ var StreamSource = class extends TypedEmitter {
2114
2812
  if (this.abortController === abortController) {
2115
2813
  this.promise = void 0;
2116
2814
  this.abortController = void 0;
2815
+ this.emit("exhausted");
2117
2816
  }
2118
2817
  },
2119
2818
  { once: true }
@@ -2122,6 +2821,7 @@ var StreamSource = class extends TypedEmitter {
2122
2821
  const promise = connectStream(
2123
2822
  {
2124
2823
  host: this.options.host,
2824
+ clientName: this.options.clientName,
2125
2825
  resolveToken: () => this.options.auth.resolveToken(),
2126
2826
  sourceProjectId: this.options.auth.sourceProjectId,
2127
2827
  abortController,
@@ -2137,8 +2837,16 @@ var StreamSource = class extends TypedEmitter {
2137
2837
  this.emit("primed", message);
2138
2838
  this.emit("connected");
2139
2839
  },
2840
+ onPing: () => this.emit("ping"),
2140
2841
  onDisconnect: () => {
2141
2842
  this.emit("disconnected");
2843
+ },
2844
+ onError: (error) => {
2845
+ this.emit("error", error);
2846
+ },
2847
+ onConnection: (reconnect) => {
2848
+ this.reconnect = reconnect;
2849
+ this.connectionStartedAt = Date.now();
2142
2850
  }
2143
2851
  }
2144
2852
  );
@@ -2154,46 +2862,39 @@ var StreamSource = class extends TypedEmitter {
2154
2862
  * Stop the stream connection.
2155
2863
  */
2156
2864
  stop() {
2157
- this.abortController?.abort();
2865
+ if (this.abortController) {
2866
+ debug(this.options.clientName, "stream.stop");
2867
+ }
2868
+ const abortController = this.abortController;
2158
2869
  this.abortController = void 0;
2159
2870
  this.promise = void 0;
2871
+ this.reconnect = void 0;
2872
+ this.revalidation?.fail(new Error("stream: stopped"));
2873
+ abortController?.abort();
2160
2874
  }
2161
2875
  };
2162
2876
 
2163
- // src/controller/tagged-data.ts
2164
- function tagData(data, origin) {
2165
- return Object.assign(data, { _origin: origin });
2166
- }
2167
- function originToMetricsSource(origin) {
2168
- switch (origin) {
2169
- case "stream":
2170
- case "poll":
2171
- case "provided":
2172
- return "in-memory";
2173
- case "fetched":
2174
- return "remote";
2175
- case "bundled":
2176
- return "embedded";
2177
- }
2178
- }
2179
-
2180
2877
  // src/controller/index.ts
2181
- function parseConfigUpdatedAt(value) {
2182
- if (typeof value === "number") return value;
2183
- if (typeof value === "string") {
2184
- const parsed = Number(value);
2185
- return Number.isNaN(parsed) ? void 0 : parsed;
2878
+ var UNKNOWN_FRESHNESS = {
2879
+ assess: () => ({ status: "unknown" })
2880
+ };
2881
+ function isUnauthorizedError(error) {
2882
+ if (error instanceof UnauthorizedError) {
2883
+ return true;
2186
2884
  }
2187
- return void 0;
2885
+ if (!(error instanceof Error)) {
2886
+ return false;
2887
+ }
2888
+ return error.message.includes("401") || "status" in error && error.status === 401;
2188
2889
  }
2189
2890
  var Controller = class {
2190
2891
  options;
2191
2892
  // State machine
2192
2893
  state = "idle";
2193
2894
  // Data state — tagged with origin
2194
- data;
2895
+ cache;
2195
2896
  // Memoized data spread for read() / getDatafile().
2196
- // Rebuilt only when `this.data` reference changes (e.g. on stream/poll update).
2897
+ // Rebuilt only when the cached data reference changes (e.g. on stream/poll update).
2197
2898
  // Holds the result of stripping `_origin`; metrics are appended per-call.
2198
2899
  dataViewSource = void 0;
2199
2900
  dataViewBase = void 0;
@@ -2201,6 +2902,10 @@ var Controller = class {
2201
2902
  streamSource;
2202
2903
  pollingSource;
2203
2904
  bundledSource;
2905
+ headerSource;
2906
+ sourceStartup;
2907
+ // Read policy while no live source confirms the cache; derived once from the options.
2908
+ degradedReadPolicy;
2204
2909
  // Usage tracking
2205
2910
  usageTracker;
2206
2911
  isFirstGetData = true;
@@ -2212,53 +2917,112 @@ var Controller = class {
2212
2917
  unauthorized = false;
2213
2918
  constructor(options) {
2214
2919
  this.options = normalizeOptions(options);
2920
+ this.cache = new DatafileCache(
2921
+ async (signal) => {
2922
+ try {
2923
+ const data = await fetchDatafile({ ...this.options, signal });
2924
+ signal.throwIfAborted();
2925
+ this.unauthorized = false;
2926
+ return data;
2927
+ } catch (error) {
2928
+ signal.throwIfAborted();
2929
+ this.noteUnauthorized(error);
2930
+ throw error;
2931
+ }
2932
+ },
2933
+ this.options.staleIfErrorMs,
2934
+ this.options.waitUntil,
2935
+ this.options.clientName
2936
+ );
2215
2937
  this.streamSource = new StreamSource(
2216
2938
  this.options,
2217
- () => this.data?.revision
2939
+ () => this.cache.revision
2218
2940
  );
2219
- this.pollingSource = new PollingSource(this.options);
2941
+ this.pollingSource = new PollingSource({
2942
+ clientName: this.options.clientName,
2943
+ polling: this.options.polling,
2944
+ staleWhileRevalidateMs: this.options.staleWhileRevalidateMs,
2945
+ refresh: () => this.cache.refresh("poll")
2946
+ });
2947
+ this.headerSource = new HeaderSource(this.options);
2948
+ this.degradedReadPolicy = this.createDegradedReadPolicy();
2220
2949
  this.bundledSource = new BundledSource({
2221
2950
  auth: this.options.auth,
2222
2951
  readBundledDefinitions
2223
2952
  });
2224
2953
  this.wireSourceEvents();
2225
2954
  if (this.options.datafile) {
2226
- this.data = tagData(this.options.datafile, "provided");
2955
+ this.cache.seed(tagData({ ...this.options.datafile }, "provided"));
2227
2956
  }
2228
2957
  this.usageTracker = new UsageTracker(this.options);
2958
+ debug(this.options.clientName, "client.created", () => ({
2959
+ ...this.debugState(),
2960
+ buildStep: this.options.buildStep,
2961
+ vercel: this.options.vercel,
2962
+ stream: this.options.stream.enabled,
2963
+ streamInitTimeoutMs: this.options.stream.initTimeoutMs,
2964
+ polling: this.options.polling.enabled,
2965
+ pollingIntervalMs: this.options.polling.intervalMs,
2966
+ pollingInitTimeoutMs: this.options.polling.initTimeoutMs,
2967
+ staleWhileRevalidateMs: this.options.staleWhileRevalidateMs,
2968
+ staleIfErrorMs: this.options.staleIfErrorMs
2969
+ }));
2229
2970
  }
2230
2971
  // Source event handlers (stored for cleanup)
2231
2972
  onStreamData = (data) => {
2973
+ debug(this.options.clientName, "stream.data", () => ({
2974
+ projectId: data.projectId,
2975
+ revision: data.revision,
2976
+ configUpdatedAt: Number(data.configUpdatedAt)
2977
+ }));
2232
2978
  this.unauthorized = false;
2233
- if (this.isNewerData(data)) {
2234
- this.data = tagData(data, "stream");
2235
- }
2979
+ this.cache.updateFromSource(data, "stream");
2236
2980
  };
2237
- onStreamPrimed = () => {
2981
+ onStreamPrimed = (message) => {
2238
2982
  this.unauthorized = false;
2983
+ const confirmed = this.cache.tryConfirm(message, "revision", "stream");
2984
+ debug(this.options.clientName, "stream.primed", () => ({
2985
+ ...this.debugState(),
2986
+ incomingRevision: message.revision,
2987
+ confirmed
2988
+ }));
2239
2989
  if (this.state === "degraded" || this.state === "initializing:stream") {
2240
- this.transition("streaming");
2990
+ this.transition("streaming", "stream-primed");
2241
2991
  }
2242
2992
  };
2993
+ onStreamPing = () => {
2994
+ debug(this.options.clientName, "stream.ping", this.debugState);
2995
+ this.cache.confirm("stream");
2996
+ };
2243
2997
  onStreamConnected = () => {
2244
2998
  if (this.state === "degraded" || this.state === "initializing:stream") {
2245
- this.transition("streaming");
2999
+ this.transition("streaming", "stream-connected");
2246
3000
  }
2247
3001
  };
2248
3002
  onStreamDisconnected = () => {
3003
+ debug(this.options.clientName, "stream.disconnected", this.debugState);
3004
+ this.cache.fail(new Error("stream: disconnected"));
2249
3005
  if (this.state === "streaming") {
2250
- this.transition("degraded");
3006
+ this.transition("degraded", "stream-disconnected");
2251
3007
  }
2252
3008
  };
2253
- onPollData = (data) => {
2254
- this.unauthorized = false;
2255
- if (this.isNewerData(data)) {
2256
- this.data = tagData(data, "poll");
3009
+ onStreamExhausted = () => {
3010
+ if (this.state !== "streaming" && this.state !== "degraded") {
3011
+ return;
2257
3012
  }
3013
+ void this.activateFallbackSource().catch(() => {
3014
+ });
2258
3015
  };
2259
- onPollError = (error) => {
3016
+ onSourceError = (error) => {
2260
3017
  this.noteUnauthorized(error);
2261
- console.error("@vercel/flags-core: Poll failed:", error);
3018
+ this.cache.fail(error);
3019
+ };
3020
+ onPollingError = (error) => {
3021
+ this.onSourceError(error);
3022
+ if (this.state === "polling" && isUnauthorizedError(error)) {
3023
+ this.pollingSource.stop();
3024
+ this.transition("degraded", "polling-unauthorized");
3025
+ }
2262
3026
  };
2263
3027
  // ---------------------------------------------------------------------------
2264
3028
  // Source event wiring
@@ -2266,24 +3030,49 @@ var Controller = class {
2266
3030
  wireSourceEvents() {
2267
3031
  this.streamSource.on("data", this.onStreamData);
2268
3032
  this.streamSource.on("primed", this.onStreamPrimed);
3033
+ this.streamSource.on("ping", this.onStreamPing);
2269
3034
  this.streamSource.on("connected", this.onStreamConnected);
2270
3035
  this.streamSource.on("disconnected", this.onStreamDisconnected);
2271
- this.pollingSource.on("data", this.onPollData);
2272
- this.pollingSource.on("error", this.onPollError);
3036
+ this.streamSource.on("exhausted", this.onStreamExhausted);
3037
+ this.streamSource.on("error", this.onSourceError);
3038
+ this.pollingSource.on("error", this.onPollingError);
2273
3039
  }
2274
3040
  unwireSourceEvents() {
2275
3041
  this.streamSource.off("data", this.onStreamData);
2276
3042
  this.streamSource.off("primed", this.onStreamPrimed);
3043
+ this.streamSource.off("ping", this.onStreamPing);
2277
3044
  this.streamSource.off("connected", this.onStreamConnected);
2278
3045
  this.streamSource.off("disconnected", this.onStreamDisconnected);
2279
- this.pollingSource.off("data", this.onPollData);
2280
- this.pollingSource.off("error", this.onPollError);
3046
+ this.streamSource.off("exhausted", this.onStreamExhausted);
3047
+ this.streamSource.off("error", this.onSourceError);
3048
+ this.pollingSource.off("error", this.onPollingError);
2281
3049
  }
2282
3050
  // ---------------------------------------------------------------------------
2283
3051
  // State machine
2284
3052
  // ---------------------------------------------------------------------------
2285
- transition(to) {
3053
+ debugState = () => ({
3054
+ state: this.state,
3055
+ mode: this.mode,
3056
+ projectId: this.cache.metadata?.projectId,
3057
+ hasData: this.cache.hasData,
3058
+ revision: this.cache.revision,
3059
+ ageMs: this.cache.ageMs,
3060
+ unauthorized: this.unauthorized
3061
+ });
3062
+ transition(to, reason) {
3063
+ const from = this.state;
2286
3064
  this.state = to;
3065
+ if (from !== to) {
3066
+ debug(this.options.clientName, "client.state", () => ({
3067
+ ...this.debugState(),
3068
+ from,
3069
+ to,
3070
+ reason
3071
+ }));
3072
+ }
3073
+ }
3074
+ get isShutdown() {
3075
+ return this.state === "shutdown";
2287
3076
  }
2288
3077
  get isConnected() {
2289
3078
  return this.state === "streaming";
@@ -2295,6 +3084,8 @@ var Controller = class {
2295
3084
  return "streaming";
2296
3085
  case "polling":
2297
3086
  return "polling";
3087
+ case "vercel":
3088
+ return "vercel";
2298
3089
  default:
2299
3090
  return "offline";
2300
3091
  }
@@ -2308,193 +3099,344 @@ var Controller = class {
2308
3099
  * Build step: datafile → bundled → one-time fetch
2309
3100
  * Streaming mode: stream → datafile → bundled
2310
3101
  * Polling mode (no stream): poll → datafile → bundled
3102
+ * Vercel mode: datafile → bundled; fetch only on a read
2311
3103
  * Offline mode (neither): datafile → bundled → one-time fetch
2312
3104
  */
2313
3105
  async initialize() {
3106
+ debug(this.options.clientName, "client.initialize", this.debugState);
2314
3107
  if (this.options.buildStep) {
2315
- this.transition("build:loading");
3108
+ this.transition("build:loading", "build-initialize");
2316
3109
  await this.initializeForBuildStep();
2317
- this.transition("build:ready");
3110
+ this.transition("build:ready", "build-data-ready");
2318
3111
  return;
2319
3112
  }
2320
- if (!this.data && this.options.datafile) {
2321
- this.data = tagData(this.options.datafile, "provided");
3113
+ if (this.isShutdown) {
3114
+ this.wireSourceEvents();
3115
+ this.transition("idle", "restart-after-shutdown");
2322
3116
  }
2323
- if (!this.data) {
3117
+ if (!this.cache.hasData && this.options.datafile) {
3118
+ this.cache.seed(tagData({ ...this.options.datafile }, "provided"));
3119
+ }
3120
+ if (!this.cache.hasData) {
2324
3121
  try {
2325
3122
  const bundled = await this.bundledSource.tryLoad();
2326
- if (bundled) {
2327
- this.data = tagData(bundled, "bundled");
2328
- }
3123
+ if (bundled) this.cache.seed(tagData({ ...bundled }, "bundled"));
2329
3124
  } catch {
2330
3125
  }
2331
3126
  }
2332
- if (this.data) {
2333
- if (this.options.stream.enabled) {
2334
- this.transition("initializing:stream");
2335
- await this.tryInitializeStream();
2336
- } else if (this.options.polling.enabled) {
2337
- this.transition("initializing:polling");
2338
- await this.tryInitializePolling();
2339
- } else {
2340
- this.transition("degraded");
2341
- }
3127
+ if (this.headerSource.isEnabled()) {
3128
+ this.transition("vercel", "header-mode-enabled");
2342
3129
  return;
2343
3130
  }
2344
- if (this.options.stream.enabled) {
2345
- this.transition("initializing:stream");
2346
- const streamSuccess = await this.tryInitializeStream();
2347
- if (streamSuccess) {
2348
- this.transition("streaming");
2349
- return;
2350
- }
2351
- } else if (this.options.polling.enabled) {
2352
- this.transition("initializing:polling");
2353
- const pollingSuccess = await this.tryInitializePolling();
2354
- if (pollingSuccess) {
2355
- this.transition("polling");
2356
- return;
2357
- }
3131
+ if (!this.options.stream.enabled && !this.options.polling.enabled) {
3132
+ await this.initializeFromFallbacks();
3133
+ return;
3134
+ }
3135
+ await this.activateFallbackSource();
3136
+ if (this.cache.hasData) return;
3137
+ if (this.unauthorized) {
3138
+ throw this.noDefinitionsError(
3139
+ ". Provide a datafile or bundled definitions."
3140
+ );
3141
+ }
3142
+ const fetched = await this.cache.resolve(this.cacheReadPolicy);
3143
+ if (!fetched.data) {
3144
+ await this.initializeFromFallbacks();
2358
3145
  }
2359
- await this.initializeFromFallbacks();
2360
3146
  }
2361
3147
  /**
2362
3148
  * Reads the current datafile with metrics.
2363
3149
  */
2364
3150
  async read() {
2365
3151
  const startTime = Date.now();
2366
- const cacheHadDefinitions = this.data !== void 0;
3152
+ const cacheHadDefinitions = this.cache.hasData;
2367
3153
  const isFirstRead = this.isFirstGetData;
2368
3154
  this.isFirstGetData = false;
2369
- const [result, cacheStatus] = await this.resolveData();
2370
- const readMs = Date.now() - startTime;
2371
- const source = originToMetricsSource(result._origin);
2372
- this.trackRead(startTime, cacheHadDefinitions, isFirstRead, source);
2373
- if (this.dataViewSource !== result) {
2374
- const { _origin, ...rest } = result;
2375
- this.dataViewBase = rest;
2376
- this.dataViewSource = result;
2377
- }
2378
- return {
2379
- ...this.dataViewBase,
2380
- metrics: {
2381
- readMs,
2382
- source,
2383
- cacheStatus,
2384
- connectionState: this.isConnected ? "connected" : "disconnected",
2385
- mode: this.mode
2386
- }
2387
- };
3155
+ debug(this.options.clientName, "client.read.start", this.debugState);
3156
+ const [result, cacheStatus] = await this.resolveData().catch((error) => {
3157
+ debug(this.options.clientName, "client.read.failed", this.debugState);
3158
+ throw error;
3159
+ });
3160
+ const datafile = this.toDatafile(result, cacheStatus, startTime);
3161
+ debug(this.options.clientName, "client.read", () => ({
3162
+ ...this.debugState(),
3163
+ ...datafile.metrics
3164
+ }));
3165
+ this.trackRead(startTime, cacheHadDefinitions, isFirstRead, datafile);
3166
+ return datafile;
2388
3167
  }
2389
3168
  /**
2390
3169
  * Shuts down the data source and releases resources.
2391
3170
  */
2392
3171
  async shutdown() {
3172
+ debug(this.options.clientName, "client.shutdown.start", this.debugState);
2393
3173
  this.unwireSourceEvents();
2394
3174
  this.streamSource.stop();
2395
3175
  this.pollingSource.stop();
2396
- this.data = this.options.datafile ? tagData(this.options.datafile, "provided") : void 0;
2397
- this.transition("shutdown");
3176
+ this.headerSource.stop();
3177
+ this.cache.clear();
3178
+ if (this.options.datafile) {
3179
+ this.cache.seed(tagData({ ...this.options.datafile }, "provided"));
3180
+ }
3181
+ this.transition("shutdown", "shutdown");
2398
3182
  await this.usageTracker.shutdown();
3183
+ debug(this.options.clientName, "client.shutdown.complete", this.debugState);
2399
3184
  }
2400
3185
  /**
2401
- * Returns the datafile with metrics.
2402
- * Uses in-memory data if available, otherwise falls back to bundled,
2403
- * then to a one-time fetch if called without prior initialization.
3186
+ * Returns the datafile with metrics as a snapshot: it never starts streaming
3187
+ * or polling. Cached data is served through the active source policy
3188
+ * (header checks and HTTP revalidation); an empty cache loads bundled
3189
+ * definitions, then performs a one-time fetch.
2404
3190
  */
2405
3191
  async getDatafile() {
3192
+ debug(this.options.clientName, "client.getDatafile.start", this.debugState);
2406
3193
  const startTime = Date.now();
2407
3194
  this.isFirstGetData = false;
2408
- let result;
2409
- let cacheStatus;
2410
- if (this.options.buildStep) {
2411
- [result, cacheStatus] = await this.resolveDataForBuildStep();
2412
- } else if (this.data) {
2413
- cacheStatus = this.isConnected ? "HIT" : "STALE";
2414
- result = this.data;
2415
- } else {
2416
- const bundled = await this.bundledSource.tryLoad();
2417
- if (bundled) {
2418
- this.data = tagData(bundled, "bundled");
2419
- result = this.data;
2420
- cacheStatus = "MISS";
2421
- } else {
2422
- try {
2423
- const fetched = await fetchDatafile({
2424
- host: this.options.host,
2425
- auth: this.options.auth,
2426
- fetch: this.options.fetch
2427
- });
2428
- this.data = tagData(fetched, "fetched");
2429
- result = this.data;
2430
- cacheStatus = "MISS";
2431
- } catch (error) {
2432
- this.noteUnauthorized(error);
2433
- throw this.noDefinitionsError(
2434
- ". Initialize the client or provide a datafile."
2435
- );
2436
- }
3195
+ const [result, cacheStatus] = await this.resolveSnapshot().catch(
3196
+ (error) => {
3197
+ debug(
3198
+ this.options.clientName,
3199
+ "client.getDatafile.failed",
3200
+ this.debugState
3201
+ );
3202
+ throw error;
2437
3203
  }
2438
- }
2439
- const source = originToMetricsSource(result._origin);
3204
+ );
3205
+ const datafile = this.toDatafile(result, cacheStatus, startTime);
3206
+ debug(this.options.clientName, "client.getDatafile", () => ({
3207
+ ...this.debugState(),
3208
+ cacheStatus,
3209
+ origin: result._origin
3210
+ }));
3211
+ return datafile;
3212
+ }
3213
+ /**
3214
+ * Returns the bundled fallback datafile.
3215
+ */
3216
+ async getFallbackDatafile() {
3217
+ return this.bundledSource.getRaw();
3218
+ }
3219
+ // ---------------------------------------------------------------------------
3220
+ // Data resolution
3221
+ // ---------------------------------------------------------------------------
3222
+ toDatafile(result, cacheStatus, startTime) {
2440
3223
  if (this.dataViewSource !== result) {
2441
3224
  const { _origin, ...rest } = result;
2442
3225
  this.dataViewBase = rest;
2443
3226
  this.dataViewSource = result;
2444
3227
  }
3228
+ debug(this.options.clientName, "client.getDatafile", () => ({
3229
+ ...this.debugState(),
3230
+ cacheStatus,
3231
+ origin: result._origin
3232
+ }));
2445
3233
  return {
2446
3234
  ...this.dataViewBase,
2447
3235
  metrics: {
2448
3236
  readMs: Date.now() - startTime,
2449
- source,
3237
+ source: originToMetricsSource(result._origin),
2450
3238
  cacheStatus,
2451
3239
  connectionState: this.isConnected ? "connected" : "disconnected",
2452
3240
  mode: this.mode
2453
3241
  }
2454
3242
  };
2455
3243
  }
2456
- /**
2457
- * Returns the bundled fallback datafile.
2458
- */
2459
- async getFallbackDatafile() {
2460
- return this.bundledSource.getRaw();
2461
- }
2462
- // ---------------------------------------------------------------------------
2463
- // Data resolution (shared by read() and getDatafile())
2464
- // ---------------------------------------------------------------------------
2465
3244
  /**
2466
3245
  * Resolves the current data, using the appropriate strategy for the
2467
3246
  * current mode. Returns tagged data and cache status.
2468
3247
  *
2469
3248
  * Build step: cached → bundled → one-time fetch
2470
- * Runtime with cache: return cached data
2471
- * Runtime without cache: stream/poll → datafile → bundled → fetch → throw
3249
+ * Runtime: source policy chooses cached data or refresh; fall back if empty.
2472
3250
  */
2473
3251
  async resolveData() {
2474
3252
  if (this.options.buildStep) {
2475
3253
  return this.resolveDataForBuildStep();
2476
3254
  }
2477
- if (this.data) {
2478
- const cacheStatus = this.isConnected ? "HIT" : "STALE";
2479
- return [this.data, cacheStatus];
3255
+ return this.resolveRuntimeData();
3256
+ }
3257
+ /** Initializes the active runtime source, then resolves through its policy. */
3258
+ async resolveRuntimeData() {
3259
+ if (this.sourceStartup) {
3260
+ debug(this.options.clientName, "source.startup.shared", this.debugState);
3261
+ await this.sourceStartup;
3262
+ }
3263
+ if (!this.cache.hasData && !this.options.stream.enabled && !this.options.polling.enabled) {
3264
+ return this.resolveStaticFallbackData();
3265
+ }
3266
+ const result = await this.cache.resolve(this.cacheReadPolicy);
3267
+ if (result.hasError || !result.data) {
3268
+ debug(this.options.clientName, "header.fallback", () => ({
3269
+ ...this.debugState(),
3270
+ reason: "source-assessment-error"
3271
+ }));
3272
+ await this.activateFallbackSource();
3273
+ return this.resolveRuntimeData();
2480
3274
  }
2481
- return this.resolveDataWithFallbacks();
3275
+ return [result.data, result.status];
3276
+ }
3277
+ /**
3278
+ * Resolves data for getDatafile() without starting a runtime source.
3279
+ * Build step: cached → bundled → one-time fetch
3280
+ * Runtime: cached data through the source policy; otherwise bundled → one-time fetch → throw
3281
+ */
3282
+ async resolveSnapshot() {
3283
+ if (this.options.buildStep) {
3284
+ return this.resolveDataForBuildStep();
3285
+ }
3286
+ if (this.sourceStartup) {
3287
+ await this.sourceStartup.catch(() => {
3288
+ });
3289
+ }
3290
+ if (this.state === "idle" && this.headerSource.isEnabled()) {
3291
+ this.transition("vercel", "header-mode-enabled");
3292
+ }
3293
+ if (!this.cache.hasData) {
3294
+ const bundled = await this.bundledSource.tryLoad();
3295
+ if (bundled) {
3296
+ this.cache.seed(tagData({ ...bundled }, "bundled"));
3297
+ }
3298
+ }
3299
+ if (this.cache.hasData || this.state === "vercel") {
3300
+ const result = await this.cache.resolve(this.cacheReadPolicy);
3301
+ const cached = result.data ?? this.cache.read();
3302
+ if (cached) {
3303
+ return [cached, result.status];
3304
+ }
3305
+ }
3306
+ try {
3307
+ await this.cache.refresh();
3308
+ } catch {
3309
+ throw this.noDefinitionsError(
3310
+ ". Initialize the client or provide a datafile."
3311
+ );
3312
+ }
3313
+ const fetched = this.cache.read();
3314
+ if (!fetched) {
3315
+ throw this.noDefinitionsError(
3316
+ ". Initialize the client or provide a datafile."
3317
+ );
3318
+ }
3319
+ return [fetched, "MISS"];
3320
+ }
3321
+ get cacheReadPolicy() {
3322
+ if (this.state === "vercel") {
3323
+ return {
3324
+ assess: this.headerSource.getAssessment(),
3325
+ retryOnFailure: true
3326
+ };
3327
+ }
3328
+ if (this.state === "streaming") {
3329
+ return {
3330
+ assess: this.streamSource.assess,
3331
+ revalidate: () => this.streamSource.revalidate()
3332
+ };
3333
+ }
3334
+ if (this.state === "polling") {
3335
+ return { assess: this.pollingSource.assess };
3336
+ }
3337
+ if (this.state === "degraded") {
3338
+ return this.degradedReadPolicy;
3339
+ }
3340
+ return UNKNOWN_FRESHNESS;
3341
+ }
3342
+ /**
3343
+ * Without a live source nothing confirms the cache, so reads apply plain
3344
+ * stale-while-revalidate over HTTP from the two public windows: data
3345
+ * refreshed within `staleWhileRevalidateMs` is served as is, older data is
3346
+ * served while refreshing in the background, and data older than that
3347
+ * window plus `staleIfErrorMs` is expired and waits for the refresh. An
3348
+ * unknown age is not known to be old, so it refreshes in the background.
3349
+ * Offline clients never refresh.
3350
+ */
3351
+ createDegradedReadPolicy() {
3352
+ if (!this.options.stream.enabled && !this.options.polling.enabled) {
3353
+ return UNKNOWN_FRESHNESS;
3354
+ }
3355
+ const { staleWhileRevalidateMs, staleIfErrorMs } = this.options;
3356
+ const expiresAfterMs = staleWhileRevalidateMs + staleIfErrorMs;
3357
+ return {
3358
+ assess: ({ ageMs }) => {
3359
+ if (ageMs <= staleWhileRevalidateMs) {
3360
+ return { status: "fresh" };
3361
+ }
3362
+ if (ageMs === Infinity || ageMs <= expiresAfterMs) {
3363
+ return { status: "stale" };
3364
+ }
3365
+ return { status: "expired" };
3366
+ },
3367
+ retryOnFailure: true
3368
+ };
3369
+ }
3370
+ /**
3371
+ * Advances through the runtime source chain. Every caller uses the same path:
3372
+ * request headers → stream → polling → direct cache refresh.
3373
+ */
3374
+ activateFallbackSource() {
3375
+ if (this.sourceStartup) {
3376
+ debug(this.options.clientName, "source.startup.shared", this.debugState);
3377
+ return this.sourceStartup;
3378
+ }
3379
+ debug(this.options.clientName, "source.startup", this.debugState);
3380
+ const startup = this.startFallbackSource().finally(() => {
3381
+ if (this.sourceStartup === startup) {
3382
+ this.sourceStartup = void 0;
3383
+ }
3384
+ debug(this.options.clientName, "source.startup.settled", this.debugState);
3385
+ });
3386
+ this.sourceStartup = startup;
3387
+ return startup;
3388
+ }
3389
+ async startFallbackSource() {
3390
+ if (this.isShutdown) {
3391
+ throw new Error("@vercel/flags-core: Client is shut down");
3392
+ }
3393
+ if ((this.state === "idle" || this.state === "vercel") && this.options.stream.enabled) {
3394
+ this.transition("initializing:stream", "start-stream");
3395
+ const outcome = await this.tryInitializeStream();
3396
+ if (this.isShutdown) {
3397
+ throw new Error("@vercel/flags-core: Client is shut down");
3398
+ }
3399
+ if (outcome === "connected" || this.isConnected) {
3400
+ this.transition("streaming", "stream-initialized");
3401
+ return;
3402
+ }
3403
+ if (outcome === "connecting" && this.streamSource.active) {
3404
+ this.transition("degraded", "stream-still-connecting");
3405
+ return;
3406
+ }
3407
+ }
3408
+ if (this.options.polling.enabled) {
3409
+ if (this.state !== "polling") {
3410
+ this.transition("polling", "start-polling");
3411
+ this.pollingSource.startInterval();
3412
+ }
3413
+ await this.initializePolling();
3414
+ if (this.isShutdown) {
3415
+ throw new Error("@vercel/flags-core: Client is shut down");
3416
+ }
3417
+ return;
3418
+ }
3419
+ this.transition("degraded", "no-active-update-source");
2482
3420
  }
2483
3421
  // ---------------------------------------------------------------------------
2484
3422
  // Stream initialization
2485
3423
  // ---------------------------------------------------------------------------
2486
3424
  /**
2487
- * Attempts to initialize via stream with timeout.
2488
- * Returns true if stream connected successfully within timeout.
3425
+ * Attempts to initialize via stream with timeout. A timeout leaves the
3426
+ * stream connecting in the background; a rejection means it gave up.
2489
3427
  */
2490
3428
  async tryInitializeStream() {
3429
+ debug(this.options.clientName, "stream.initialize", () => ({
3430
+ timeoutMs: this.options.stream.initTimeoutMs,
3431
+ ...this.debugState()
3432
+ }));
2491
3433
  if (this.options.stream.initTimeoutMs <= 0) {
2492
3434
  try {
2493
3435
  await this.streamSource.start();
2494
- return true;
3436
+ return "connected";
2495
3437
  } catch (error) {
2496
3438
  this.noteUnauthorized(error);
2497
- return false;
3439
+ return "failed";
2498
3440
  }
2499
3441
  }
2500
3442
  let timeoutId;
@@ -2511,72 +3453,80 @@ var Controller = class {
2511
3453
  ]);
2512
3454
  clearTimeout(timeoutId);
2513
3455
  if (result === "timeout") {
3456
+ debug(this.options.clientName, "stream.initialize.timeout", () => ({
3457
+ timeoutMs: this.options.stream.initTimeoutMs,
3458
+ ...this.debugState()
3459
+ }));
2514
3460
  console.warn(
2515
3461
  "@vercel/flags-core: Stream initialization timeout, falling back while continuing to connect in the background"
2516
3462
  );
2517
- void this.streamSource.start().catch(() => {
2518
- });
2519
- return false;
3463
+ if (this.streamSource.active) {
3464
+ void this.streamSource.start().catch(() => {
3465
+ });
3466
+ }
3467
+ return "connecting";
2520
3468
  }
2521
- return true;
3469
+ return "connected";
2522
3470
  } catch (error) {
2523
3471
  clearTimeout(timeoutId);
2524
3472
  this.noteUnauthorized(error);
2525
- return false;
3473
+ return "failed";
2526
3474
  }
2527
3475
  }
2528
3476
  // ---------------------------------------------------------------------------
2529
3477
  // Polling initialization
2530
3478
  // ---------------------------------------------------------------------------
2531
3479
  /**
2532
- * Attempts to initialize via polling with timeout.
2533
- * Returns true if first poll succeeded within timeout.
2534
- *
2535
- * Only used when streaming is disabled and polling is the primary source.
3480
+ * Waits for the first poll whenever polling becomes the active runtime source.
3481
+ * On timeout, initialization falls back while the pending poll and interval
3482
+ * continue in the background. Poll errors propagate if no data is cached or
3483
+ * the client is shutting down.
2536
3484
  */
2537
- async tryInitializePolling() {
2538
- const pollPromise = this.pollingSource.poll();
2539
- if (this.options.polling.initTimeoutMs <= 0) {
2540
- try {
2541
- await pollPromise;
2542
- if (this.data) {
2543
- this.pollingSource.startInterval();
2544
- return true;
2545
- }
2546
- return false;
2547
- } catch {
2548
- return false;
3485
+ async initializePolling() {
3486
+ debug(this.options.clientName, "poll.initialize", () => ({
3487
+ timeoutMs: this.options.polling.initTimeoutMs,
3488
+ ...this.debugState()
3489
+ }));
3490
+ const poll = this.pollingSource.poll().catch((error) => {
3491
+ if (!this.cache.hasData || this.isShutdown) {
3492
+ throw error;
2549
3493
  }
2550
- }
2551
- let timeoutId;
2552
- const timeoutPromise = new Promise((resolve) => {
2553
- timeoutId = setTimeout(
2554
- () => resolve("timeout"),
2555
- this.options.polling.initTimeoutMs
3494
+ debug(
3495
+ this.options.clientName,
3496
+ "poll.initialize.fallback",
3497
+ this.debugState
2556
3498
  );
2557
3499
  });
3500
+ const timeoutMs = this.options.polling.initTimeoutMs;
3501
+ if (timeoutMs <= 0) {
3502
+ await poll;
3503
+ return;
3504
+ }
3505
+ let timeoutId;
2558
3506
  try {
2559
- const result = await Promise.race([pollPromise, timeoutPromise]);
2560
- clearTimeout(timeoutId);
2561
- if (result === "timeout") {
3507
+ const outcome = await Promise.race([
3508
+ poll,
3509
+ new Promise((resolve) => {
3510
+ timeoutId = setTimeout(() => resolve("timeout"), timeoutMs);
3511
+ })
3512
+ ]);
3513
+ if (outcome === "timeout") {
3514
+ debug(this.options.clientName, "poll.initialize.timeout", () => ({
3515
+ timeoutMs,
3516
+ ...this.debugState()
3517
+ }));
2562
3518
  console.warn(
2563
3519
  "@vercel/flags-core: Polling initialization timeout, falling back while continuing to poll in the background"
2564
3520
  );
2565
- return false;
2566
- }
2567
- if (this.data) {
2568
- this.pollingSource.startInterval();
2569
- return true;
2570
3521
  }
2571
- return false;
2572
- } catch {
3522
+ } finally {
2573
3523
  clearTimeout(timeoutId);
2574
- return false;
2575
3524
  }
2576
3525
  }
2577
3526
  noteUnauthorized(error) {
2578
- if (error instanceof UnauthorizedError || error instanceof Error && error.message.includes("401")) {
3527
+ if (isUnauthorizedError(error)) {
2579
3528
  this.unauthorized = true;
3529
+ debug(this.options.clientName, "client.unauthorized", this.debugState);
2580
3530
  }
2581
3531
  }
2582
3532
  // ---------------------------------------------------------------------------
@@ -2586,44 +3536,41 @@ var Controller = class {
2586
3536
  * Initializes data for build step environments.
2587
3537
  */
2588
3538
  async initializeForBuildStep() {
2589
- if (this.data) return;
3539
+ if (this.cache.hasData) return;
2590
3540
  if (!this.buildDataPromise) {
2591
3541
  this.buildDataPromise = this.loadBuildData();
2592
3542
  }
2593
- this.data = await this.buildDataPromise;
3543
+ this.cache.seed(await this.buildDataPromise);
2594
3544
  }
2595
3545
  /**
2596
3546
  * Retrieves data during build steps.
2597
3547
  * Concurrent callers share a single load promise. The first caller to
2598
- * populate `this.data` gets cacheStatus MISS; subsequent callers get HIT.
3548
+ * populate the cache gets cacheStatus MISS; subsequent callers get HIT.
2599
3549
  */
2600
3550
  async resolveDataForBuildStep() {
2601
- if (this.data) {
2602
- return [this.data, "HIT"];
3551
+ const cached = this.cache.read();
3552
+ if (cached) {
3553
+ return [cached, "HIT"];
2603
3554
  }
2604
3555
  if (!this.buildDataPromise) {
2605
3556
  this.buildDataPromise = this.loadBuildData();
2606
3557
  }
2607
3558
  const data = await this.buildDataPromise;
2608
- if (!this.data) {
2609
- this.data = data;
2610
- return [data, "MISS"];
3559
+ const loaded = this.cache.read();
3560
+ if (loaded) {
3561
+ return [loaded, "HIT"];
2611
3562
  }
2612
- return [this.data, "HIT"];
3563
+ return [this.cache.seedAndRead(data), "MISS"];
2613
3564
  }
2614
3565
  /**
2615
3566
  * Loads data for a build step: bundled → one-time fetch.
2616
3567
  */
2617
3568
  async loadBuildData() {
2618
3569
  const bundled = await this.bundledSource.tryLoad();
2619
- if (bundled) return tagData(bundled, "bundled");
3570
+ if (bundled) return tagData({ ...bundled }, "bundled");
2620
3571
  try {
2621
- const fetched = await fetchDatafile({
2622
- host: this.options.host,
2623
- auth: this.options.auth,
2624
- fetch: this.options.fetch
2625
- });
2626
- return tagData(fetched, "fetched");
3572
+ const fetched = await this.fetchOnce();
3573
+ return tagData({ ...fetched, fetchedAt: Date.now() }, "fetched");
2627
3574
  } catch (error) {
2628
3575
  this.noteUnauthorized(error);
2629
3576
  }
@@ -2634,30 +3581,45 @@ var Controller = class {
2634
3581
  // ---------------------------------------------------------------------------
2635
3582
  // Fallback helpers
2636
3583
  // ---------------------------------------------------------------------------
3584
+ fetchOnce() {
3585
+ return fetchDatafile({
3586
+ host: this.options.host,
3587
+ clientName: this.options.clientName,
3588
+ auth: this.options.auth,
3589
+ fetch: this.options.fetch
3590
+ });
3591
+ }
3592
+ /**
3593
+ * Stores a last-resort fetch as a source response, which clears any failure,
3594
+ * and serves it through the cache boundary.
3595
+ */
3596
+ acceptLastResortFetch(fetched) {
3597
+ this.cache.updateFromSource(fetched, "fetched");
3598
+ const remote = this.cache.read();
3599
+ if (!remote) {
3600
+ throw new Error("@vercel/flags-core: Fetch returned no definitions");
3601
+ }
3602
+ return remote;
3603
+ }
2637
3604
  /**
2638
3605
  * Shared fallback chain used by both initialize() and resolveData().
2639
3606
  */
2640
3607
  async initializeFromFallbacks() {
2641
- this.transition("initializing:fallback");
2642
- if (this.data) {
2643
- this.transition("degraded");
3608
+ this.transition("initializing:fallback", "initialize-static-fallback");
3609
+ if (this.cache.hasData) {
3610
+ this.transition("degraded", "cached-fallback");
2644
3611
  return;
2645
3612
  }
2646
3613
  const bundled = await this.bundledSource.tryLoad();
2647
3614
  if (bundled) {
2648
- this.data = tagData(bundled, "bundled");
2649
- this.transition("degraded");
3615
+ this.cache.seed(tagData({ ...bundled }, "bundled"));
3616
+ this.transition("degraded", "bundled-fallback");
2650
3617
  return;
2651
3618
  }
2652
3619
  if (!this.options.stream.enabled && !this.options.polling.enabled) {
2653
3620
  try {
2654
- const fetched = await fetchDatafile({
2655
- host: this.options.host,
2656
- auth: this.options.auth,
2657
- fetch: this.options.fetch
2658
- });
2659
- this.data = tagData(fetched, "fetched");
2660
- this.transition("degraded");
3621
+ this.acceptLastResortFetch(await this.fetchOnce());
3622
+ this.transition("degraded", "fetched-fallback");
2661
3623
  return;
2662
3624
  } catch {
2663
3625
  }
@@ -2669,6 +3631,7 @@ var Controller = class {
2669
3631
  * starts with either "." or " during build.".
2670
3632
  */
2671
3633
  noDefinitionsError(detail) {
3634
+ debug(this.options.clientName, "client.no-definitions", this.debugState);
2672
3635
  const { sourceProjectId } = this.options.auth;
2673
3636
  const reason = this.unauthorized && sourceProjectId ? ` Request was ${unauthorizedMessage(sourceProjectId)}` : "";
2674
3637
  return new Error(
@@ -2676,92 +3639,57 @@ var Controller = class {
2676
3639
  );
2677
3640
  }
2678
3641
  /**
2679
- * Retrieves data using the fallback chain (called when no cached data exists).
2680
- * Streaming mode: stream → datafile → bundled.
2681
- * Polling mode: poll → datafile → bundled.
2682
- * Offline mode: datafile → bundled → one-time fetch.
3642
+ * Retrieves data when the cache is empty in offline mode:
3643
+ * datafile → bundled → one-time fetch.
2683
3644
  */
2684
- async resolveDataWithFallbacks() {
2685
- if (this.options.stream.enabled) {
2686
- this.transition("initializing:stream");
2687
- const streamSuccess = await this.tryInitializeStream();
2688
- if (streamSuccess && this.data) {
2689
- this.transition("streaming");
2690
- return [this.data, "MISS"];
2691
- }
2692
- } else if (this.options.polling.enabled) {
2693
- this.transition("initializing:polling");
2694
- const pollingSuccess = await this.tryInitializePolling();
2695
- if (pollingSuccess && this.data) {
2696
- this.transition("polling");
2697
- return [this.data, "MISS"];
2698
- }
2699
- }
2700
- this.transition("initializing:fallback");
3645
+ async resolveStaticFallbackData() {
3646
+ const cached = this.cache.read();
3647
+ if (cached) return [cached, "STALE"];
3648
+ this.transition("initializing:fallback", "read-static-fallback");
2701
3649
  if (this.options.datafile) {
2702
- this.data = tagData(this.options.datafile, "provided");
2703
- this.transition("degraded");
2704
- return [this.data, "STALE"];
3650
+ const provided = this.cache.seedAndRead(
3651
+ tagData({ ...this.options.datafile }, "provided")
3652
+ );
3653
+ this.transition("degraded", "provided-fallback");
3654
+ return [provided, "STALE"];
2705
3655
  }
2706
3656
  const bundled = await this.bundledSource.tryLoad();
2707
3657
  if (bundled) {
2708
3658
  console.warn("@vercel/flags-core: Using bundled definitions as fallback");
2709
- this.data = tagData(bundled, "bundled");
2710
- this.transition("degraded");
2711
- return [this.data, "STALE"];
3659
+ const embedded = this.cache.seedAndRead(
3660
+ tagData({ ...bundled }, "bundled")
3661
+ );
3662
+ this.transition("degraded", "bundled-fallback");
3663
+ return [embedded, "STALE"];
2712
3664
  }
2713
3665
  if (!this.options.stream.enabled && !this.options.polling.enabled) {
3666
+ let fetched;
2714
3667
  try {
2715
- const fetched = await fetchDatafile({
2716
- host: this.options.host,
2717
- auth: this.options.auth,
2718
- fetch: this.options.fetch
2719
- });
2720
- this.data = tagData(fetched, "fetched");
2721
- this.transition("degraded");
2722
- return [this.data, "MISS"];
3668
+ fetched = await this.fetchOnce();
2723
3669
  } catch {
2724
3670
  }
3671
+ if (fetched) {
3672
+ const remote = this.acceptLastResortFetch(fetched);
3673
+ this.transition("degraded", "fetched-fallback");
3674
+ return [remote, "MISS"];
3675
+ }
2725
3676
  }
2726
3677
  throw this.noDefinitionsError(
2727
3678
  ". Provide a datafile or bundled definitions."
2728
3679
  );
2729
3680
  }
2730
3681
  // ---------------------------------------------------------------------------
2731
- // Data comparison
2732
- // ---------------------------------------------------------------------------
2733
- /**
2734
- * Checks if the incoming data is newer than the current in-memory data.
2735
- * Returns true if the update should proceed, false if it should be skipped.
2736
- *
2737
- * Always accepts the update if:
2738
- * - There is no current data
2739
- * - The current data has no configUpdatedAt
2740
- * - The incoming data has no configUpdatedAt
2741
- *
2742
- * Skips the update only when both have configUpdatedAt and incoming is not newer.
2743
- */
2744
- isNewerData(incoming) {
2745
- if (!this.data) return true;
2746
- const currentTs = parseConfigUpdatedAt(this.data.configUpdatedAt);
2747
- const incomingTs = parseConfigUpdatedAt(incoming.configUpdatedAt);
2748
- if (currentTs === void 0 || incomingTs === void 0) {
2749
- return true;
2750
- }
2751
- return incomingTs > currentTs;
2752
- }
2753
- // ---------------------------------------------------------------------------
2754
3682
  // Usage tracking
2755
3683
  // ---------------------------------------------------------------------------
2756
3684
  /**
2757
3685
  * Tracks a read operation for usage analytics.
2758
3686
  * During build steps, only the first read is tracked.
2759
3687
  */
2760
- trackRead(startTime, cacheHadDefinitions, isFirstRead, source) {
3688
+ trackRead(startTime, cacheHadDefinitions, isFirstRead, datafile) {
2761
3689
  if (this.unauthorized) return;
2762
3690
  if (this.options.buildStep && this.buildReadTracked) return;
2763
3691
  if (this.options.buildStep) this.buildReadTracked = true;
2764
- const configOrigin = source === "embedded" ? "embedded" : "in-memory";
3692
+ const configOrigin = datafile.metrics.source === "embedded" ? "embedded" : "in-memory";
2765
3693
  const cacheAction = this.state === "streaming" ? "FOLLOWING" : this.state === "polling" ? "REFRESHING" : "NONE";
2766
3694
  const mode = this.mode;
2767
3695
  const trackOptions = {
@@ -2772,11 +3700,11 @@ var Controller = class {
2772
3700
  duration: Date.now() - startTime,
2773
3701
  mode: mode === "streaming" ? "stream" : mode === "polling" ? "poll" : mode
2774
3702
  };
2775
- const configUpdatedAt = this.data?.configUpdatedAt;
3703
+ const configUpdatedAt = datafile.configUpdatedAt;
2776
3704
  if (typeof configUpdatedAt === "number") {
2777
3705
  trackOptions.configUpdatedAt = configUpdatedAt;
2778
3706
  }
2779
- const revision = this.data?.revision;
3707
+ const revision = datafile.revision;
2780
3708
  if (typeof revision === "number") {
2781
3709
  trackOptions.revision = revision;
2782
3710
  }
@@ -2870,4 +3798,4 @@ export {
2870
3798
  resetDefaultFlagsClient,
2871
3799
  createClient
2872
3800
  };
2873
- //# sourceMappingURL=chunk-SLNLPFKU.js.map
3801
+ //# sourceMappingURL=chunk-SYXANNZ4.js.map