@vercel/flags-core 1.8.1 → 1.9.0-004baf8-20260915082635

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @vercel/flags-core
2
2
 
3
+ ## 1.9.0-004baf8-20260915082635
4
+
5
+ ### Minor Changes
6
+
7
+ - [#498](https://github.com/vercel/flags/pull/498) [`da0b044`](https://github.com/vercel/flags/commit/da0b044c2f2efb1c61900c43f21fa21813a3ef20) Thanks [@luismeyer](https://github.com/luismeyer)! - Add a header-driven `vercel` client mode that uses request config-version timestamps instead of streaming or polling. Reuse fresh definitions, refresh in the background when the request version is up to 10 seconds newer than the cached configuration, and block for a refresh when the gap is larger. Deduplicate concurrent refreshes, allow retries after fetch failures, and discard late responses after shutdown.
8
+
3
9
  ## 1.8.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -12,18 +12,39 @@ npm i @vercel/flags-core
12
12
 
13
13
  ## Usage
14
14
 
15
+ Create a shared client at module scope, but evaluate flags inside a request handler when using Vercel OIDC authentication. `evaluate()` and `bulkEvaluate()` initialize the client automatically on first use; you do not need to call `initialize()` first.
16
+
17
+ For example, in an Express app deployed to Vercel:
18
+
15
19
  ```ts
20
+ import express from 'express';
16
21
  import { createClient } from '@vercel/flags-core';
17
22
 
18
- const client = createClient(process.env.FLAGS!);
19
-
20
- await client.initialize();
23
+ const app = express();
24
+ const client = createClient(); // Uses Vercel OIDC; does not initialize yet.
21
25
 
22
- const result = await client.evaluate<boolean>('show-new-feature', false, {
23
- user: { id: 'user-123' },
26
+ app.get('/api/feature', async (_req, res) => {
27
+ const result = await client.evaluate<boolean>('show-new-feature', false);
28
+ res.json({ enabled: result.value });
24
29
  });
30
+
31
+ export default app;
25
32
  ```
26
33
 
34
+ Outside Vercel, pass an SDK key explicitly: `createClient(process.env.FLAGS)`.
35
+
36
+ ### Initialization and request-scoped OIDC
37
+
38
+ On Vercel, the OIDC token can be supplied through the current request context (`x-vercel-oidc-token`). It is not guaranteed to be available while modules are loading. Creating the client at module scope is safe, but starting `client.initialize()` there can attempt authentication before a request exists.
39
+
40
+ Do not store a module-scope initialization promise and await it later in a handler. Calling `initialize()` starts the work immediately; awaiting the promise inside a request does not move that work into the request context. If the promise rejects, every handler awaiting that same promise will reject before reaching evaluation and its fallback handling.
41
+
42
+ This also applies when definitions are embedded at build time: with OIDC authentication, the client uses the token's `project_id` claim to select the embedded definitions. An importable `@vercel/flags-definitions` module alone is not enough.
43
+
44
+ For local development, `vercel env pull` writes an OIDC token to `.env.local`. If your local runner loads that file, the environment-variable fallback can make module-scope initialization appear to work, even though request-scoped authentication on a deployment requires deferring it.
45
+
46
+ Explicit `await client.initialize()` is optional and can be useful when you want to wait for initialization and handle its errors yourself. Only call it once authentication is available: inside a request handler for request-scoped OIDC, or during startup when using an SDK key or an already-available environment token. For normal flag evaluation, prefer calling `evaluate()` or `bulkEvaluate()` directly in the handler.
47
+
27
48
  ## Evaluation Metrics
28
49
 
29
50
  To associate evaluation metrics with an environment, pass the
@@ -558,7 +558,7 @@ function bulkEvaluate(flags, shared) {
558
558
  }
559
559
 
560
560
  // package.json
561
- var version = "1.8.1";
561
+ var version = "1.9.0-004baf8-20260915082635";
562
562
 
563
563
  // src/lib/report-value.ts
564
564
  function internalReportValue(key, value, data) {
@@ -1479,6 +1479,131 @@ async function fetchDatafile(options) {
1479
1479
  }
1480
1480
  }
1481
1481
 
1482
+ // src/controller/header-source.ts
1483
+ import { waitUntil as waitUntil3 } from "@vercel/functions";
1484
+
1485
+ // src/controller/tagged-data.ts
1486
+ function tagData(data, origin) {
1487
+ return Object.assign(data, { _origin: origin });
1488
+ }
1489
+ function originToMetricsSource(origin) {
1490
+ switch (origin) {
1491
+ case "stream":
1492
+ case "poll":
1493
+ case "provided":
1494
+ return "in-memory";
1495
+ case "fetched":
1496
+ return "remote";
1497
+ case "bundled":
1498
+ return "embedded";
1499
+ }
1500
+ }
1501
+
1502
+ // src/controller/typed-emitter.ts
1503
+ var TypedEmitter = class {
1504
+ handlers = /* @__PURE__ */ new Map();
1505
+ on(event, handler) {
1506
+ let set = this.handlers.get(event);
1507
+ if (!set) {
1508
+ set = /* @__PURE__ */ new Set();
1509
+ this.handlers.set(event, set);
1510
+ }
1511
+ set.add(handler);
1512
+ }
1513
+ off(event, handler) {
1514
+ this.handlers.get(event)?.delete(handler);
1515
+ }
1516
+ emit(event, ...args) {
1517
+ const set = this.handlers.get(event);
1518
+ if (set) {
1519
+ for (const handler of set) {
1520
+ handler(...args);
1521
+ }
1522
+ }
1523
+ }
1524
+ };
1525
+
1526
+ // src/controller/header-source.ts
1527
+ var HeaderSource = class extends TypedEmitter {
1528
+ options;
1529
+ abortController;
1530
+ promise;
1531
+ constructor(options) {
1532
+ super();
1533
+ this.options = options;
1534
+ }
1535
+ fetchDatafile() {
1536
+ if (this.promise) return this.promise;
1537
+ const abortController = new AbortController();
1538
+ this.abortController = abortController;
1539
+ this.promise = fetchDatafile({
1540
+ ...this.options,
1541
+ signal: abortController.signal
1542
+ }).then((data) => {
1543
+ abortController.signal.throwIfAborted();
1544
+ this.emit("data", data);
1545
+ return data;
1546
+ }).finally(() => {
1547
+ if (this.abortController === abortController) {
1548
+ this.promise = void 0;
1549
+ this.abortController = void 0;
1550
+ }
1551
+ });
1552
+ return this.promise;
1553
+ }
1554
+ getUpdatedAtHeader(projectId) {
1555
+ const ctx = getRequestContext();
1556
+ const header = ctx.headers?.["x-vercel-edge-config-versions"] ?? ctx.headers?.["edge-config-versions"];
1557
+ if (!header) {
1558
+ return;
1559
+ }
1560
+ const prefix = `flags_${projectId}=`;
1561
+ const value = header.split(";").map((part) => part.trim()).find((part) => part.startsWith(prefix))?.slice(prefix.length);
1562
+ const timestamp = Number(value);
1563
+ return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : void 0;
1564
+ }
1565
+ async resolveData(currentData) {
1566
+ if (!currentData.configUpdatedAt) {
1567
+ return;
1568
+ }
1569
+ const updatedAtHeader = this.getUpdatedAtHeader(currentData.projectId);
1570
+ if (!updatedAtHeader) {
1571
+ return;
1572
+ }
1573
+ const currentUpdatedAt = Number(currentData.configUpdatedAt);
1574
+ if (updatedAtHeader <= currentUpdatedAt) {
1575
+ return [currentData, "HIT"];
1576
+ }
1577
+ if (updatedAtHeader <= currentUpdatedAt + 1e4) {
1578
+ const pending = this.fetchDatafile();
1579
+ const signal = this.abortController?.signal;
1580
+ const background = pending.catch((error) => {
1581
+ if (!signal?.aborted) {
1582
+ console.error("@vercel/flags-core: Header refresh failed:", error);
1583
+ }
1584
+ });
1585
+ waitUntil3(background);
1586
+ return [currentData, "STALE"];
1587
+ }
1588
+ const data = await this.fetchDatafile();
1589
+ return [tagData(data, "fetched"), "MISS"];
1590
+ }
1591
+ read(currentData) {
1592
+ return this.resolveData(currentData);
1593
+ }
1594
+ isAvailable(projectId) {
1595
+ return !!this.getUpdatedAtHeader(projectId);
1596
+ }
1597
+ /**
1598
+ * Abort the current header-driven fetch and discard its pending work.
1599
+ */
1600
+ stop() {
1601
+ this.abortController?.abort();
1602
+ this.abortController = void 0;
1603
+ this.promise = void 0;
1604
+ }
1605
+ };
1606
+
1482
1607
  // src/controller/normalized-options.ts
1483
1608
  var DEFAULT_STREAM_INIT_TIMEOUT_MS = 3e3;
1484
1609
  var DEFAULT_POLLING_INTERVAL_MS = 3e4;
@@ -1530,30 +1655,6 @@ function normalizeOptions(options) {
1530
1655
  };
1531
1656
  }
1532
1657
 
1533
- // src/controller/typed-emitter.ts
1534
- var TypedEmitter = class {
1535
- handlers = /* @__PURE__ */ new Map();
1536
- on(event, handler) {
1537
- let set = this.handlers.get(event);
1538
- if (!set) {
1539
- set = /* @__PURE__ */ new Set();
1540
- this.handlers.set(event, set);
1541
- }
1542
- set.add(handler);
1543
- }
1544
- off(event, handler) {
1545
- this.handlers.get(event)?.delete(handler);
1546
- }
1547
- emit(event, ...args) {
1548
- const set = this.handlers.get(event);
1549
- if (set) {
1550
- for (const handler of set) {
1551
- handler(...args);
1552
- }
1553
- }
1554
- }
1555
- };
1556
-
1557
1658
  // src/controller/polling-source.ts
1558
1659
  var PollingSource = class extends TypedEmitter {
1559
1660
  config;
@@ -1893,23 +1994,6 @@ var StreamSource = class extends TypedEmitter {
1893
1994
  }
1894
1995
  };
1895
1996
 
1896
- // src/controller/tagged-data.ts
1897
- function tagData(data, origin) {
1898
- return Object.assign(data, { _origin: origin });
1899
- }
1900
- function originToMetricsSource(origin) {
1901
- switch (origin) {
1902
- case "stream":
1903
- case "poll":
1904
- case "provided":
1905
- return "in-memory";
1906
- case "fetched":
1907
- return "remote";
1908
- case "bundled":
1909
- return "embedded";
1910
- }
1911
- }
1912
-
1913
1997
  // src/controller/index.ts
1914
1998
  function parseConfigUpdatedAt(value) {
1915
1999
  if (typeof value === "number") return value;
@@ -1934,6 +2018,7 @@ var Controller = class {
1934
2018
  streamSource;
1935
2019
  pollingSource;
1936
2020
  bundledSource;
2021
+ headerSource;
1937
2022
  // Usage tracking
1938
2023
  usageTracker;
1939
2024
  isFirstGetData = true;
@@ -1953,6 +2038,7 @@ var Controller = class {
1953
2038
  auth: this.options.auth,
1954
2039
  readBundledDefinitions
1955
2040
  });
2041
+ this.headerSource = new HeaderSource(this.options);
1956
2042
  this.wireSourceEvents();
1957
2043
  if (this.options.datafile) {
1958
2044
  this.data = tagData(this.options.datafile, "provided");
@@ -1988,6 +2074,11 @@ var Controller = class {
1988
2074
  onPollError = (error) => {
1989
2075
  console.error("@vercel/flags-core: Poll failed:", error);
1990
2076
  };
2077
+ onFetchedData = (data) => {
2078
+ if (this.isNewerData(data)) {
2079
+ this.data = tagData(data, "fetched");
2080
+ }
2081
+ };
1991
2082
  // ---------------------------------------------------------------------------
1992
2083
  // Source event wiring
1993
2084
  // ---------------------------------------------------------------------------
@@ -1998,6 +2089,7 @@ var Controller = class {
1998
2089
  this.streamSource.on("disconnected", this.onStreamDisconnected);
1999
2090
  this.pollingSource.on("data", this.onPollData);
2000
2091
  this.pollingSource.on("error", this.onPollError);
2092
+ this.headerSource.on("data", this.onFetchedData);
2001
2093
  }
2002
2094
  unwireSourceEvents() {
2003
2095
  this.streamSource.off("data", this.onStreamData);
@@ -2006,6 +2098,7 @@ var Controller = class {
2006
2098
  this.streamSource.off("disconnected", this.onStreamDisconnected);
2007
2099
  this.pollingSource.off("data", this.onPollData);
2008
2100
  this.pollingSource.off("error", this.onPollError);
2101
+ this.headerSource.off("data", this.onFetchedData);
2009
2102
  }
2010
2103
  // ---------------------------------------------------------------------------
2011
2104
  // State machine
@@ -2023,6 +2116,8 @@ var Controller = class {
2023
2116
  return "streaming";
2024
2117
  case "polling":
2025
2118
  return "polling";
2119
+ case "vercel":
2120
+ return "vercel";
2026
2121
  default:
2027
2122
  return "offline";
2028
2123
  }
@@ -2058,7 +2153,9 @@ var Controller = class {
2058
2153
  }
2059
2154
  }
2060
2155
  if (this.data) {
2061
- if (this.options.stream.enabled) {
2156
+ if (this.headerSource.isAvailable(this.data.projectId)) {
2157
+ this.transition("vercel");
2158
+ } else if (this.options.stream.enabled) {
2062
2159
  this.transition("initializing:stream");
2063
2160
  await this.tryInitializeStream();
2064
2161
  } else if (this.options.polling.enabled) {
@@ -2121,6 +2218,7 @@ var Controller = class {
2121
2218
  this.unwireSourceEvents();
2122
2219
  this.streamSource.stop();
2123
2220
  this.pollingSource.stop();
2221
+ this.headerSource.stop();
2124
2222
  this.data = this.options.datafile ? tagData(this.options.datafile, "provided") : void 0;
2125
2223
  this.transition("shutdown");
2126
2224
  await this.usageTracker.shutdown();
@@ -2202,6 +2300,12 @@ var Controller = class {
2202
2300
  return this.resolveDataForBuildStep();
2203
2301
  }
2204
2302
  if (this.data) {
2303
+ if (this.mode === "vercel") {
2304
+ const result = await this.headerSource.read(this.data);
2305
+ if (result) {
2306
+ return result;
2307
+ }
2308
+ }
2205
2309
  const cacheStatus = this.isConnected ? "HIT" : "STALE";
2206
2310
  return [this.data, cacheStatus];
2207
2311
  }
@@ -2683,4 +2787,4 @@ export {
2683
2787
  resetDefaultFlagsClient,
2684
2788
  createClient
2685
2789
  };
2686
- //# sourceMappingURL=chunk-DYSF37WV.js.map
2790
+ //# sourceMappingURL=chunk-KURHLZJF.js.map