@quonfig/node 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -55,7 +55,8 @@ new Quonfig({
55
55
  initTimeout: 10000, // Init timeout in ms (default: 10000)
56
56
  onNoDefault: "error", // "error" | "warn" | "ignore" (default: "error")
57
57
  globalContext: { ... }, // Context applied to all evaluations
58
- datafile: "./config.json", // Load from file instead of API
58
+ datadir: "./workspace-data", // Load local workspace directories instead of API
59
+ datafile: "./config.json", // Legacy local envelope path
59
60
  });
60
61
  ```
61
62
 
package/dist/index.cjs CHANGED
@@ -93,7 +93,7 @@ module.exports = __toCommonJS(index_exports);
93
93
 
94
94
  // src/quonfig.ts
95
95
  var import_crypto3 = require("crypto");
96
- var import_fs = require("fs");
96
+ var import_fs2 = require("fs");
97
97
 
98
98
  // src/store.ts
99
99
  var ConfigStore = class {
@@ -918,12 +918,14 @@ function valueTypeForCoerced(valueType) {
918
918
  var SDK_VERSION = "0.1.0";
919
919
  var DEFAULT_TELEMETRY_URL = "https://telemetry.quonfig.com";
920
920
  var Transport = class {
921
- baseUrl;
921
+ baseUrls;
922
+ activeBaseUrl;
922
923
  telemetryBaseUrl;
923
924
  sdkKey;
924
925
  etag = "";
925
- constructor(baseUrl, sdkKey, telemetryBaseUrl) {
926
- this.baseUrl = baseUrl.replace(/\/$/, "");
926
+ constructor(baseUrls, sdkKey, telemetryBaseUrl) {
927
+ this.baseUrls = baseUrls.map((u) => u.replace(/\/$/, ""));
928
+ this.activeBaseUrl = this.baseUrls[0];
927
929
  const envUrl = process.env.QUONFIG_TELEMETRY_URL;
928
930
  const url = envUrl || telemetryBaseUrl || DEFAULT_TELEMETRY_URL;
929
931
  this.telemetryBaseUrl = url.replace(/\/$/, "");
@@ -950,30 +952,41 @@ var Transport = class {
950
952
  /**
951
953
  * Fetch configs from GET /api/v2/configs with ETag caching.
952
954
  *
955
+ * Tries each base URL in order. Returns the first successful result.
953
956
  * Returns `{ notChanged: true }` if the server responds with 304.
954
957
  */
955
958
  async fetchConfigs() {
956
- const headers = this.getHeaders();
957
- if (this.etag) {
958
- headers["If-None-Match"] = this.etag;
959
- }
960
- const response = await fetch(`${this.baseUrl}/api/v2/configs`, {
961
- method: "GET",
962
- headers
963
- });
964
- if (response.status === 304) {
965
- return { notChanged: true };
966
- }
967
- if (!response.ok) {
968
- const body = await response.text().catch(() => "");
969
- throw new Error(`Unexpected status ${response.status}: ${body}`);
970
- }
971
- const etag = response.headers.get("ETag");
972
- if (etag) {
973
- this.etag = etag;
959
+ let lastError;
960
+ for (const baseUrl of this.baseUrls) {
961
+ try {
962
+ const headers = this.getHeaders();
963
+ if (this.etag) {
964
+ headers["If-None-Match"] = this.etag;
965
+ }
966
+ const response = await fetch(`${baseUrl}/api/v2/configs`, {
967
+ method: "GET",
968
+ headers
969
+ });
970
+ if (response.status === 304) {
971
+ this.activeBaseUrl = baseUrl;
972
+ return { notChanged: true };
973
+ }
974
+ if (!response.ok) {
975
+ const body = await response.text().catch(() => "");
976
+ throw new Error(`Unexpected status ${response.status} from ${baseUrl}: ${body}`);
977
+ }
978
+ const etag = response.headers.get("ETag");
979
+ if (etag) {
980
+ this.etag = etag;
981
+ }
982
+ this.activeBaseUrl = baseUrl;
983
+ const envelope = await response.json();
984
+ return { envelope, notChanged: false };
985
+ } catch (err) {
986
+ lastError = err instanceof Error ? err : new Error(String(err));
987
+ }
974
988
  }
975
- const envelope = await response.json();
976
- return { envelope, notChanged: false };
989
+ throw lastError ?? new Error("All API URLs failed");
977
990
  }
978
991
  /**
979
992
  * Post telemetry data to the telemetry endpoint.
@@ -994,9 +1007,10 @@ var Transport = class {
994
1007
  }
995
1008
  /**
996
1009
  * Get the SSE URL for config streaming.
1010
+ * Uses whichever base URL last succeeded for fetchConfigs.
997
1011
  */
998
1012
  getSSEUrl() {
999
- return `${this.baseUrl}/api/v2/sse/config`;
1013
+ return `${this.activeBaseUrl}/api/v2/sse/config`;
1000
1014
  }
1001
1015
  /**
1002
1016
  * Get auth headers for SSE connection.
@@ -1101,7 +1115,10 @@ function shouldLog(args) {
1101
1115
  while (loggerNameWithPrefix.includes(".")) {
1102
1116
  const resolvedLevel = getConfig(loggerNameWithPrefix);
1103
1117
  if (resolvedLevel !== void 0) {
1104
- return Number(resolvedLevel) <= desiredLevel;
1118
+ const resolvedLevelNum = parseLevel(resolvedLevel);
1119
+ if (resolvedLevelNum !== void 0) {
1120
+ return resolvedLevelNum <= desiredLevel;
1121
+ }
1105
1122
  }
1106
1123
  loggerNameWithPrefix = loggerNameWithPrefix.slice(
1107
1124
  0,
@@ -1111,6 +1128,87 @@ function shouldLog(args) {
1111
1128
  return defaultLevel <= desiredLevel;
1112
1129
  }
1113
1130
 
1131
+ // src/datadir.ts
1132
+ var import_fs = require("fs");
1133
+ var import_path = require("path");
1134
+ var CONFIG_SUBDIRS = ["configs", "feature-flags", "segments", "schemas", "log-levels"];
1135
+ function loadEnvelopeFromDatadir(datadir) {
1136
+ const environmentId = loadEnvironmentId((0, import_path.join)(datadir, "environments.json"));
1137
+ const configs = [];
1138
+ for (const subdir of CONFIG_SUBDIRS) {
1139
+ const dir = (0, import_path.join)(datadir, subdir);
1140
+ if (!(0, import_fs.existsSync)(dir)) {
1141
+ continue;
1142
+ }
1143
+ const filenames = (0, import_fs.readdirSync)(dir).filter((filename) => filename.endsWith(".json")).sort((a, b) => a.localeCompare(b));
1144
+ for (const filename of filenames) {
1145
+ const raw = JSON.parse((0, import_fs.readFileSync)((0, import_path.join)(dir, filename), "utf-8"));
1146
+ configs.push(toConfigResponse(raw, environmentId));
1147
+ }
1148
+ }
1149
+ return {
1150
+ configs,
1151
+ meta: {
1152
+ version: `datadir:${datadir}`,
1153
+ environment: environmentId
1154
+ }
1155
+ };
1156
+ }
1157
+ function loadEnvironmentId(environmentsPath) {
1158
+ if (!(0, import_fs.existsSync)(environmentsPath)) {
1159
+ throw new Error(`[quonfig] Datadir is missing environments.json: ${environmentsPath}`);
1160
+ }
1161
+ const environments = JSON.parse((0, import_fs.readFileSync)(environmentsPath, "utf-8"));
1162
+ const candidates = normalizeEnvironmentCandidates(
1163
+ isWrappedEnvironmentList(environments) ? environments.environments : environments
1164
+ );
1165
+ if (candidates.length === 0) {
1166
+ return "";
1167
+ }
1168
+ return candidates[0];
1169
+ }
1170
+ function isWrappedEnvironmentList(value) {
1171
+ return Boolean(
1172
+ value && typeof value === "object" && !Array.isArray(value) && "environments" in value
1173
+ );
1174
+ }
1175
+ function normalizeEnvironmentCandidates(environments) {
1176
+ if (!environments) {
1177
+ return [];
1178
+ }
1179
+ if (Array.isArray(environments)) {
1180
+ return environments.map((entry) => {
1181
+ if (typeof entry === "string") {
1182
+ return entry;
1183
+ }
1184
+ if (entry && typeof entry === "object") {
1185
+ return entry.id ?? entry.name;
1186
+ }
1187
+ return void 0;
1188
+ }).filter((entry) => typeof entry === "string" && entry.length > 0);
1189
+ }
1190
+ if (environments && typeof environments === "object") {
1191
+ const values = Object.values(environments).map((value) => typeof value === "string" && value.length > 0 ? value : void 0).filter((value) => typeof value === "string");
1192
+ if (values.length > 0) {
1193
+ return values;
1194
+ }
1195
+ return Object.keys(environments).filter((key) => key.length > 0);
1196
+ }
1197
+ return [];
1198
+ }
1199
+ function toConfigResponse(raw, environmentId) {
1200
+ const environment = raw.environments?.find((candidate) => candidate.id === environmentId);
1201
+ return {
1202
+ id: raw.id ?? "",
1203
+ key: raw.key,
1204
+ type: raw.type,
1205
+ valueType: raw.valueType,
1206
+ sendToClientSdk: raw.sendToClientSdk ?? false,
1207
+ default: raw.default ?? { rules: [] },
1208
+ environment
1209
+ };
1210
+ }
1211
+
1114
1212
  // src/telemetry/evaluationSummaries.ts
1115
1213
  var EvaluationSummaryCollector = class {
1116
1214
  enabled;
@@ -1380,7 +1478,10 @@ var TelemetryReporter = class {
1380
1478
  };
1381
1479
 
1382
1480
  // src/quonfig.ts
1383
- var DEFAULT_API_URL = "https://api.quonfig.com";
1481
+ var DEFAULT_API_URLS = [
1482
+ "https://primary.quonfig.com",
1483
+ "https://secondary.quonfig.com"
1484
+ ];
1384
1485
  var DEFAULT_POLL_INTERVAL = 6e4;
1385
1486
  var DEFAULT_INIT_TIMEOUT = 1e4;
1386
1487
  var DEFAULT_LOG_LEVEL = 5;
@@ -1421,6 +1522,9 @@ var BoundQuonfig = class _BoundQuonfig {
1421
1522
  contexts: mergeContexts(this.boundContexts, args.contexts)
1422
1523
  });
1423
1524
  }
1525
+ async flush() {
1526
+ return this.client.flush();
1527
+ }
1424
1528
  keys() {
1425
1529
  return this.client.keys();
1426
1530
  }
@@ -1430,7 +1534,7 @@ var BoundQuonfig = class _BoundQuonfig {
1430
1534
  };
1431
1535
  var Quonfig = class {
1432
1536
  sdkKey;
1433
- apiUrl;
1537
+ apiUrls;
1434
1538
  telemetryUrl;
1435
1539
  enableSSE;
1436
1540
  enablePolling;
@@ -1439,6 +1543,7 @@ var Quonfig = class {
1439
1543
  onNoDefault;
1440
1544
  globalContext;
1441
1545
  initTimeout;
1546
+ datadir;
1442
1547
  datafile;
1443
1548
  store;
1444
1549
  evaluator;
@@ -1456,7 +1561,10 @@ var Quonfig = class {
1456
1561
  exampleContexts;
1457
1562
  constructor(options) {
1458
1563
  this.sdkKey = options.sdkKey;
1459
- this.apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
1564
+ this.apiUrls = options.apiUrls ?? (options.apiUrl ? [options.apiUrl] : DEFAULT_API_URLS);
1565
+ if (this.apiUrls.length === 0) {
1566
+ throw new Error("[quonfig] apiUrls must not be empty");
1567
+ }
1460
1568
  this.telemetryUrl = options.telemetryUrl;
1461
1569
  this.enableSSE = options.enableSSE ?? true;
1462
1570
  this.enablePolling = options.enablePolling ?? false;
@@ -1465,12 +1573,13 @@ var Quonfig = class {
1465
1573
  this.onNoDefault = options.onNoDefault ?? "error";
1466
1574
  this.globalContext = options.globalContext;
1467
1575
  this.initTimeout = options.initTimeout ?? DEFAULT_INIT_TIMEOUT;
1576
+ this.datadir = options.datadir;
1468
1577
  this.datafile = options.datafile;
1469
1578
  this.instanceHash = (0, import_crypto3.randomUUID)();
1470
1579
  this.store = new ConfigStore();
1471
1580
  this.evaluator = new Evaluator(this.store);
1472
1581
  this.resolver = new Resolver(this.store, this.evaluator);
1473
- this.transport = new Transport(this.apiUrl, this.sdkKey, this.telemetryUrl);
1582
+ this.transport = new Transport(this.apiUrls, this.sdkKey, this.telemetryUrl);
1474
1583
  const contextUploadMode = options.contextUploadMode ?? "periodic_example";
1475
1584
  this.evaluationSummaries = new EvaluationSummaryCollector(
1476
1585
  options.collectEvaluationSummaries ?? true
@@ -1479,14 +1588,14 @@ var Quonfig = class {
1479
1588
  this.exampleContexts = new ExampleContextCollector(contextUploadMode);
1480
1589
  }
1481
1590
  /**
1482
- * Initialize the SDK. Downloads configs from the API (or loads from datafile)
1591
+ * Initialize the SDK. Downloads configs from the API (or loads from datadir/datafile)
1483
1592
  * and starts background update mechanisms (SSE/polling).
1484
1593
  *
1485
1594
  * Must be called before using any get* methods.
1486
1595
  */
1487
1596
  async init() {
1488
- if (this.datafile) {
1489
- this.loadDatafile();
1597
+ if (this.datadir || this.datafile) {
1598
+ this.loadLocalData();
1490
1599
  this.initialized = true;
1491
1600
  return;
1492
1601
  }
@@ -1654,6 +1763,22 @@ var Quonfig = class {
1654
1763
  inContext(contexts) {
1655
1764
  return new BoundQuonfig(this, mergeContexts(this.globalContext, contexts));
1656
1765
  }
1766
+ /**
1767
+ * Flush pending telemetry data immediately. Useful in serverless environments
1768
+ * (Vercel, Lambda) where the process may be frozen before the background
1769
+ * timer fires.
1770
+ *
1771
+ * ```typescript
1772
+ * const value = quonfig.get("my-flag", contexts);
1773
+ * await quonfig.flush();
1774
+ * return NextResponse.json({ value });
1775
+ * ```
1776
+ */
1777
+ async flush() {
1778
+ if (this.telemetryReporter) {
1779
+ await this.telemetryReporter.sync();
1780
+ }
1781
+ }
1657
1782
  /**
1658
1783
  * Close the SDK. Stops SSE, polling, and telemetry.
1659
1784
  */
@@ -1691,19 +1816,24 @@ var Quonfig = class {
1691
1816
  return void 0;
1692
1817
  }
1693
1818
  }
1694
- loadDatafile() {
1695
- let data;
1696
- if (typeof this.datafile === "string") {
1697
- const raw = (0, import_fs.readFileSync)(this.datafile, "utf-8");
1698
- data = JSON.parse(raw);
1699
- } else if (typeof this.datafile === "object") {
1700
- data = this.datafile;
1701
- } else {
1702
- throw new Error("Invalid datafile option");
1703
- }
1819
+ loadLocalData() {
1820
+ const data = this.loadLocalEnvelope();
1704
1821
  this.store.update(data);
1705
1822
  this.environmentId = data.meta.environment;
1706
1823
  }
1824
+ loadLocalEnvelope() {
1825
+ if (this.datadir) {
1826
+ return loadEnvelopeFromDatadir(this.datadir);
1827
+ }
1828
+ if (typeof this.datafile === "string") {
1829
+ const raw = (0, import_fs2.readFileSync)(this.datafile, "utf-8");
1830
+ return JSON.parse(raw);
1831
+ }
1832
+ if (typeof this.datafile === "object") {
1833
+ return this.datafile;
1834
+ }
1835
+ throw new Error("Invalid local configuration: expected datadir or datafile");
1836
+ }
1707
1837
  async fetchAndInstall() {
1708
1838
  const result = await this.transport.fetchConfigs();
1709
1839
  if (result.notChanged) {
@@ -1797,11 +1927,28 @@ var Client = class {
1797
1927
  }
1798
1928
  async post(path, payload) {
1799
1929
  const url = `${this.apiUrl}${path}`;
1930
+ const isORPC = path.startsWith("/api/v1/");
1800
1931
  this.log("ApiClient", `POST ${url}`);
1801
- return fetch(url, {
1932
+ const body = isORPC ? JSON.stringify({ json: payload }) : JSON.stringify(payload);
1933
+ const raw = await fetch(url, {
1802
1934
  method: "POST",
1803
1935
  headers: this.headers(),
1804
- body: JSON.stringify(payload)
1936
+ body
1937
+ });
1938
+ if (!isORPC) return raw;
1939
+ const text = await raw.text();
1940
+ let unwrapped = text;
1941
+ try {
1942
+ const parsed = JSON.parse(text);
1943
+ if (parsed && typeof parsed === "object" && "json" in parsed) {
1944
+ unwrapped = JSON.stringify(parsed.json);
1945
+ }
1946
+ } catch {
1947
+ }
1948
+ return new Response(unwrapped, {
1949
+ status: raw.status,
1950
+ statusText: raw.statusText,
1951
+ headers: raw.headers
1805
1952
  });
1806
1953
  }
1807
1954
  async put(path, payload) {