@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.
- package/CHANGELOG.md +28 -0
- package/README.md +184 -0
- package/dist/{chunk-TDZBUVBG.js → chunk-D4522FGO.js} +1295 -367
- package/dist/chunk-D4522FGO.js.map +1 -0
- package/dist/{chunk-PGKH4XQ7.cjs → chunk-ITB5XQD3.cjs} +1335 -407
- package/dist/chunk-ITB5XQD3.cjs.map +1 -0
- package/dist/{chunk-SLNLPFKU.js → chunk-SYXANNZ4.js} +1295 -367
- package/dist/chunk-SYXANNZ4.js.map +1 -0
- package/dist/{chunk-W742GGZU.cjs → chunk-W6776MKF.cjs} +1336 -408
- package/dist/chunk-W6776MKF.cjs.map +1 -0
- package/dist/index.default.cjs +2 -2
- package/dist/index.default.d.cts +104 -39
- package/dist/index.default.d.ts +104 -39
- package/dist/index.default.js +1 -1
- package/dist/index.next-js.cjs +2 -2
- package/dist/index.next-js.d.cts +104 -39
- package/dist/index.next-js.d.ts +104 -39
- package/dist/index.next-js.js +1 -1
- package/dist/openfeature.default.cjs +2 -2
- package/dist/openfeature.default.d.cts +1 -1
- package/dist/openfeature.default.d.ts +1 -1
- package/dist/openfeature.default.js +1 -1
- package/dist/openfeature.next-js.cjs +2 -2
- package/dist/openfeature.next-js.d.cts +1 -1
- package/dist/openfeature.next-js.d.ts +1 -1
- package/dist/openfeature.next-js.js +1 -1
- package/dist/{types-1OK52NNi.d.cts → types-DpVlT3fI.d.cts} +7 -1
- package/dist/{types-1OK52NNi.d.ts → types-DpVlT3fI.d.ts} +7 -1
- package/package.json +1 -1
- package/dist/chunk-PGKH4XQ7.cjs.map +0 -1
- package/dist/chunk-SLNLPFKU.js.map +0 -1
- package/dist/chunk-TDZBUVBG.js.map +0 -1
- 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.
|
|
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
|
|
1049
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
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)
|
|
1079
|
-
|
|
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 =
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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 (!
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
() =>
|
|
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
|
-
|
|
1684
|
-
);
|
|
2089
|
+
);
|
|
2090
|
+
}, DEFAULT_FETCH_TIMEOUT_MS);
|
|
1685
2091
|
let delay;
|
|
1686
|
-
const fetchAttempt = async () => {
|
|
1687
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
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
|
|
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
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2878
|
+
var UNKNOWN_FRESHNESS = {
|
|
2879
|
+
assess: () => ({ status: "unknown" })
|
|
2880
|
+
};
|
|
2881
|
+
function isUnauthorizedError(error) {
|
|
2882
|
+
if (error instanceof UnauthorizedError) {
|
|
2883
|
+
return true;
|
|
2186
2884
|
}
|
|
2187
|
-
|
|
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
|
-
|
|
2895
|
+
cache;
|
|
2195
2896
|
// Memoized data spread for read() / getDatafile().
|
|
2196
|
-
// Rebuilt only when
|
|
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.
|
|
2939
|
+
() => this.cache.revision
|
|
2218
2940
|
);
|
|
2219
|
-
this.pollingSource = new PollingSource(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2254
|
-
this.
|
|
2255
|
-
|
|
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
|
-
|
|
3016
|
+
onSourceError = (error) => {
|
|
2260
3017
|
this.noteUnauthorized(error);
|
|
2261
|
-
|
|
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.
|
|
2272
|
-
this.
|
|
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.
|
|
2280
|
-
this.
|
|
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
|
-
|
|
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 (
|
|
2321
|
-
this.
|
|
3113
|
+
if (this.isShutdown) {
|
|
3114
|
+
this.wireSourceEvents();
|
|
3115
|
+
this.transition("idle", "restart-after-shutdown");
|
|
2322
3116
|
}
|
|
2323
|
-
if (!this.
|
|
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.
|
|
2333
|
-
|
|
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.
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
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.
|
|
3152
|
+
const cacheHadDefinitions = this.cache.hasData;
|
|
2367
3153
|
const isFirstRead = this.isFirstGetData;
|
|
2368
3154
|
this.isFirstGetData = false;
|
|
2369
|
-
|
|
2370
|
-
const
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
this.
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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.
|
|
2397
|
-
this.
|
|
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
|
-
*
|
|
2403
|
-
*
|
|
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
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
3436
|
+
return "connected";
|
|
2495
3437
|
} catch (error) {
|
|
2496
3438
|
this.noteUnauthorized(error);
|
|
2497
|
-
return
|
|
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
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
3463
|
+
if (this.streamSource.active) {
|
|
3464
|
+
void this.streamSource.start().catch(() => {
|
|
3465
|
+
});
|
|
3466
|
+
}
|
|
3467
|
+
return "connecting";
|
|
2520
3468
|
}
|
|
2521
|
-
return
|
|
3469
|
+
return "connected";
|
|
2522
3470
|
} catch (error) {
|
|
2523
3471
|
clearTimeout(timeoutId);
|
|
2524
3472
|
this.noteUnauthorized(error);
|
|
2525
|
-
return
|
|
3473
|
+
return "failed";
|
|
2526
3474
|
}
|
|
2527
3475
|
}
|
|
2528
3476
|
// ---------------------------------------------------------------------------
|
|
2529
3477
|
// Polling initialization
|
|
2530
3478
|
// ---------------------------------------------------------------------------
|
|
2531
3479
|
/**
|
|
2532
|
-
*
|
|
2533
|
-
*
|
|
2534
|
-
*
|
|
2535
|
-
*
|
|
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
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
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
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
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
|
|
2560
|
-
|
|
2561
|
-
|
|
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
|
-
|
|
2572
|
-
} catch {
|
|
3522
|
+
} finally {
|
|
2573
3523
|
clearTimeout(timeoutId);
|
|
2574
|
-
return false;
|
|
2575
3524
|
}
|
|
2576
3525
|
}
|
|
2577
3526
|
noteUnauthorized(error) {
|
|
2578
|
-
if (error
|
|
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.
|
|
3539
|
+
if (this.cache.hasData) return;
|
|
2590
3540
|
if (!this.buildDataPromise) {
|
|
2591
3541
|
this.buildDataPromise = this.loadBuildData();
|
|
2592
3542
|
}
|
|
2593
|
-
this.
|
|
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
|
|
3548
|
+
* populate the cache gets cacheStatus MISS; subsequent callers get HIT.
|
|
2599
3549
|
*/
|
|
2600
3550
|
async resolveDataForBuildStep() {
|
|
2601
|
-
|
|
2602
|
-
|
|
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
|
-
|
|
2609
|
-
|
|
2610
|
-
return [
|
|
3559
|
+
const loaded = this.cache.read();
|
|
3560
|
+
if (loaded) {
|
|
3561
|
+
return [loaded, "HIT"];
|
|
2611
3562
|
}
|
|
2612
|
-
return [this.data, "
|
|
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
|
|
2622
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2655
|
-
|
|
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
|
|
2680
|
-
*
|
|
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
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
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
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
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
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
3703
|
+
const configUpdatedAt = datafile.configUpdatedAt;
|
|
2776
3704
|
if (typeof configUpdatedAt === "number") {
|
|
2777
3705
|
trackOptions.configUpdatedAt = configUpdatedAt;
|
|
2778
3706
|
}
|
|
2779
|
-
const 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-
|
|
3801
|
+
//# sourceMappingURL=chunk-SYXANNZ4.js.map
|