@quonfig/node 0.0.1 → 0.0.2

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.
@@ -1111,6 +1125,87 @@ function shouldLog(args) {
1111
1125
  return defaultLevel <= desiredLevel;
1112
1126
  }
1113
1127
 
1128
+ // src/datadir.ts
1129
+ var import_fs = require("fs");
1130
+ var import_path = require("path");
1131
+ var CONFIG_SUBDIRS = ["configs", "feature-flags", "segments", "schemas", "log-levels"];
1132
+ function loadEnvelopeFromDatadir(datadir) {
1133
+ const environmentId = loadEnvironmentId((0, import_path.join)(datadir, "environments.json"));
1134
+ const configs = [];
1135
+ for (const subdir of CONFIG_SUBDIRS) {
1136
+ const dir = (0, import_path.join)(datadir, subdir);
1137
+ if (!(0, import_fs.existsSync)(dir)) {
1138
+ continue;
1139
+ }
1140
+ const filenames = (0, import_fs.readdirSync)(dir).filter((filename) => filename.endsWith(".json")).sort((a, b) => a.localeCompare(b));
1141
+ for (const filename of filenames) {
1142
+ const raw = JSON.parse((0, import_fs.readFileSync)((0, import_path.join)(dir, filename), "utf-8"));
1143
+ configs.push(toConfigResponse(raw, environmentId));
1144
+ }
1145
+ }
1146
+ return {
1147
+ configs,
1148
+ meta: {
1149
+ version: `datadir:${datadir}`,
1150
+ environment: environmentId
1151
+ }
1152
+ };
1153
+ }
1154
+ function loadEnvironmentId(environmentsPath) {
1155
+ if (!(0, import_fs.existsSync)(environmentsPath)) {
1156
+ throw new Error(`[quonfig] Datadir is missing environments.json: ${environmentsPath}`);
1157
+ }
1158
+ const environments = JSON.parse((0, import_fs.readFileSync)(environmentsPath, "utf-8"));
1159
+ const candidates = normalizeEnvironmentCandidates(
1160
+ isWrappedEnvironmentList(environments) ? environments.environments : environments
1161
+ );
1162
+ if (candidates.length === 0) {
1163
+ return "";
1164
+ }
1165
+ return candidates[0];
1166
+ }
1167
+ function isWrappedEnvironmentList(value) {
1168
+ return Boolean(
1169
+ value && typeof value === "object" && !Array.isArray(value) && "environments" in value
1170
+ );
1171
+ }
1172
+ function normalizeEnvironmentCandidates(environments) {
1173
+ if (!environments) {
1174
+ return [];
1175
+ }
1176
+ if (Array.isArray(environments)) {
1177
+ return environments.map((entry) => {
1178
+ if (typeof entry === "string") {
1179
+ return entry;
1180
+ }
1181
+ if (entry && typeof entry === "object") {
1182
+ return entry.id ?? entry.name;
1183
+ }
1184
+ return void 0;
1185
+ }).filter((entry) => typeof entry === "string" && entry.length > 0);
1186
+ }
1187
+ if (environments && typeof environments === "object") {
1188
+ const values = Object.values(environments).map((value) => typeof value === "string" && value.length > 0 ? value : void 0).filter((value) => typeof value === "string");
1189
+ if (values.length > 0) {
1190
+ return values;
1191
+ }
1192
+ return Object.keys(environments).filter((key) => key.length > 0);
1193
+ }
1194
+ return [];
1195
+ }
1196
+ function toConfigResponse(raw, environmentId) {
1197
+ const environment = raw.environments?.find((candidate) => candidate.id === environmentId);
1198
+ return {
1199
+ id: raw.id ?? "",
1200
+ key: raw.key,
1201
+ type: raw.type,
1202
+ valueType: raw.valueType,
1203
+ sendToClientSdk: raw.sendToClientSdk ?? false,
1204
+ default: raw.default ?? { rules: [] },
1205
+ environment
1206
+ };
1207
+ }
1208
+
1114
1209
  // src/telemetry/evaluationSummaries.ts
1115
1210
  var EvaluationSummaryCollector = class {
1116
1211
  enabled;
@@ -1380,7 +1475,10 @@ var TelemetryReporter = class {
1380
1475
  };
1381
1476
 
1382
1477
  // src/quonfig.ts
1383
- var DEFAULT_API_URL = "https://api.quonfig.com";
1478
+ var DEFAULT_API_URLS = [
1479
+ "https://primary.quonfig.com",
1480
+ "https://secondary.quonfig.com"
1481
+ ];
1384
1482
  var DEFAULT_POLL_INTERVAL = 6e4;
1385
1483
  var DEFAULT_INIT_TIMEOUT = 1e4;
1386
1484
  var DEFAULT_LOG_LEVEL = 5;
@@ -1430,7 +1528,7 @@ var BoundQuonfig = class _BoundQuonfig {
1430
1528
  };
1431
1529
  var Quonfig = class {
1432
1530
  sdkKey;
1433
- apiUrl;
1531
+ apiUrls;
1434
1532
  telemetryUrl;
1435
1533
  enableSSE;
1436
1534
  enablePolling;
@@ -1439,6 +1537,7 @@ var Quonfig = class {
1439
1537
  onNoDefault;
1440
1538
  globalContext;
1441
1539
  initTimeout;
1540
+ datadir;
1442
1541
  datafile;
1443
1542
  store;
1444
1543
  evaluator;
@@ -1456,7 +1555,10 @@ var Quonfig = class {
1456
1555
  exampleContexts;
1457
1556
  constructor(options) {
1458
1557
  this.sdkKey = options.sdkKey;
1459
- this.apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
1558
+ this.apiUrls = options.apiUrls ?? (options.apiUrl ? [options.apiUrl] : DEFAULT_API_URLS);
1559
+ if (this.apiUrls.length === 0) {
1560
+ throw new Error("[quonfig] apiUrls must not be empty");
1561
+ }
1460
1562
  this.telemetryUrl = options.telemetryUrl;
1461
1563
  this.enableSSE = options.enableSSE ?? true;
1462
1564
  this.enablePolling = options.enablePolling ?? false;
@@ -1465,12 +1567,13 @@ var Quonfig = class {
1465
1567
  this.onNoDefault = options.onNoDefault ?? "error";
1466
1568
  this.globalContext = options.globalContext;
1467
1569
  this.initTimeout = options.initTimeout ?? DEFAULT_INIT_TIMEOUT;
1570
+ this.datadir = options.datadir;
1468
1571
  this.datafile = options.datafile;
1469
1572
  this.instanceHash = (0, import_crypto3.randomUUID)();
1470
1573
  this.store = new ConfigStore();
1471
1574
  this.evaluator = new Evaluator(this.store);
1472
1575
  this.resolver = new Resolver(this.store, this.evaluator);
1473
- this.transport = new Transport(this.apiUrl, this.sdkKey, this.telemetryUrl);
1576
+ this.transport = new Transport(this.apiUrls, this.sdkKey, this.telemetryUrl);
1474
1577
  const contextUploadMode = options.contextUploadMode ?? "periodic_example";
1475
1578
  this.evaluationSummaries = new EvaluationSummaryCollector(
1476
1579
  options.collectEvaluationSummaries ?? true
@@ -1479,14 +1582,14 @@ var Quonfig = class {
1479
1582
  this.exampleContexts = new ExampleContextCollector(contextUploadMode);
1480
1583
  }
1481
1584
  /**
1482
- * Initialize the SDK. Downloads configs from the API (or loads from datafile)
1585
+ * Initialize the SDK. Downloads configs from the API (or loads from datadir/datafile)
1483
1586
  * and starts background update mechanisms (SSE/polling).
1484
1587
  *
1485
1588
  * Must be called before using any get* methods.
1486
1589
  */
1487
1590
  async init() {
1488
- if (this.datafile) {
1489
- this.loadDatafile();
1591
+ if (this.datadir || this.datafile) {
1592
+ this.loadLocalData();
1490
1593
  this.initialized = true;
1491
1594
  return;
1492
1595
  }
@@ -1691,19 +1794,24 @@ var Quonfig = class {
1691
1794
  return void 0;
1692
1795
  }
1693
1796
  }
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
- }
1797
+ loadLocalData() {
1798
+ const data = this.loadLocalEnvelope();
1704
1799
  this.store.update(data);
1705
1800
  this.environmentId = data.meta.environment;
1706
1801
  }
1802
+ loadLocalEnvelope() {
1803
+ if (this.datadir) {
1804
+ return loadEnvelopeFromDatadir(this.datadir);
1805
+ }
1806
+ if (typeof this.datafile === "string") {
1807
+ const raw = (0, import_fs2.readFileSync)(this.datafile, "utf-8");
1808
+ return JSON.parse(raw);
1809
+ }
1810
+ if (typeof this.datafile === "object") {
1811
+ return this.datafile;
1812
+ }
1813
+ throw new Error("Invalid local configuration: expected datadir or datafile");
1814
+ }
1707
1815
  async fetchAndInstall() {
1708
1816
  const result = await this.transport.fetchConfigs();
1709
1817
  if (result.notChanged) {
@@ -1797,11 +1905,28 @@ var Client = class {
1797
1905
  }
1798
1906
  async post(path, payload) {
1799
1907
  const url = `${this.apiUrl}${path}`;
1908
+ const isORPC = path.startsWith("/api/v1/");
1800
1909
  this.log("ApiClient", `POST ${url}`);
1801
- return fetch(url, {
1910
+ const body = isORPC ? JSON.stringify({ json: payload }) : JSON.stringify(payload);
1911
+ const raw = await fetch(url, {
1802
1912
  method: "POST",
1803
1913
  headers: this.headers(),
1804
- body: JSON.stringify(payload)
1914
+ body
1915
+ });
1916
+ if (!isORPC) return raw;
1917
+ const text = await raw.text();
1918
+ let unwrapped = text;
1919
+ try {
1920
+ const parsed = JSON.parse(text);
1921
+ if (parsed && typeof parsed === "object" && "json" in parsed) {
1922
+ unwrapped = JSON.stringify(parsed.json);
1923
+ }
1924
+ } catch {
1925
+ }
1926
+ return new Response(unwrapped, {
1927
+ status: raw.status,
1928
+ statusText: raw.statusText,
1929
+ headers: raw.headers
1805
1930
  });
1806
1931
  }
1807
1932
  async put(path, payload) {