@smplkit/sdk 1.2.0 → 1.2.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/dist/index.js CHANGED
@@ -1,7 +1,3 @@
1
- import {
2
- ConfigRuntime
3
- } from "./chunk-2VYY5OMH.js";
4
-
5
1
  // src/config/client.ts
6
2
  import createClient from "openapi-fetch";
7
3
 
@@ -47,6 +43,13 @@ var SmplConflictError = class extends SmplError {
47
43
  Object.setPrototypeOf(this, new.target.prototype);
48
44
  }
49
45
  };
46
+ var SmplNotConnectedError = class extends SmplError {
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = "SmplNotConnectedError";
50
+ Object.setPrototypeOf(this, new.target.prototype);
51
+ }
52
+ };
50
53
  var SmplValidationError = class extends SmplError {
51
54
  constructor(message, statusCode, responseBody) {
52
55
  super(message, statusCode ?? 422, responseBody);
@@ -55,6 +58,34 @@ var SmplValidationError = class extends SmplError {
55
58
  }
56
59
  };
57
60
 
61
+ // src/config/resolve.ts
62
+ function deepMerge(base, override) {
63
+ const result = { ...base };
64
+ for (const [key, value] of Object.entries(override)) {
65
+ if (key in result && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) && typeof value === "object" && value !== null && !Array.isArray(value)) {
66
+ result[key] = deepMerge(
67
+ result[key],
68
+ value
69
+ );
70
+ } else {
71
+ result[key] = value;
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ function resolveChain(chain, environment) {
77
+ let accumulated = {};
78
+ for (let i = chain.length - 1; i >= 0; i--) {
79
+ const config = chain[i];
80
+ const baseValues = config.items ?? {};
81
+ const envEntry = (config.environments ?? {})[environment];
82
+ const envValues = envEntry !== null && envEntry !== void 0 && typeof envEntry === "object" && !Array.isArray(envEntry) ? envEntry.values ?? {} : {};
83
+ const configResolved = deepMerge(baseValues, envValues);
84
+ accumulated = deepMerge(accumulated, configResolved);
85
+ }
86
+ return accumulated;
87
+ }
88
+
58
89
  // src/config/types.ts
59
90
  var Config = class {
60
91
  /** UUID of the config. */
@@ -181,46 +212,6 @@ var Config = class {
181
212
  await this.setValues(existing, environment);
182
213
  }
183
214
  }
184
- /**
185
- * Connect to this config for runtime value resolution.
186
- *
187
- * Eagerly fetches this config and its full parent chain, resolves values
188
- * for the given environment via deep merge, and returns a
189
- * {@link ConfigRuntime} with a fully populated local cache.
190
- *
191
- * A background WebSocket connection is started for real-time updates.
192
- * If the WebSocket fails to connect, the runtime operates in cache-only
193
- * mode and reconnects automatically.
194
- *
195
- * Supports both `await` and `await using` (TypeScript 5.2+)::
196
- *
197
- * ```typescript
198
- * // Simple await
199
- * const runtime = await config.connect("production");
200
- * try { ... } finally { await runtime.close(); }
201
- *
202
- * // await using (auto-close)
203
- * await using runtime = await config.connect("production");
204
- * ```
205
- *
206
- * @param environment - The environment to resolve for (e.g. `"production"`).
207
- * @param options.timeout - Milliseconds to wait for the initial fetch.
208
- */
209
- async connect(environment, options) {
210
- const { ConfigRuntime: ConfigRuntime2 } = await import("./runtime-MIIY5ZNG.js");
211
- const timeout = options?.timeout ?? 3e4;
212
- const chain = await this._buildChain(timeout);
213
- return new ConfigRuntime2({
214
- configKey: this.key,
215
- configId: this.id,
216
- environment,
217
- chain,
218
- apiKey: this._client._apiKey,
219
- baseUrl: this._client._baseUrl,
220
- fetchChain: () => this._buildChain(timeout),
221
- sharedWs: this._client._getSharedWs ? this._client._getSharedWs() : null
222
- });
223
- }
224
215
  /**
225
216
  * Walk the parent chain and return config data objects, child-to-root.
226
217
  * @internal
@@ -367,6 +358,10 @@ var ConfigClient = class {
367
358
  _http;
368
359
  /** @internal — returns the shared WebSocket for real-time updates. */
369
360
  _getSharedWs;
361
+ /** @internal — set by SmplClient after construction. */
362
+ _parent = null;
363
+ _configCache = {};
364
+ _connected = false;
370
365
  /** @internal */
371
366
  constructor(apiKey, timeout) {
372
367
  this._apiKey = apiKey;
@@ -464,6 +459,44 @@ var ConfigClient = class {
464
459
  wrapFetchError(err);
465
460
  }
466
461
  }
462
+ /**
463
+ * Fetch all configs, resolve values for the environment, and cache.
464
+ * @internal — called by SmplClient.connect().
465
+ */
466
+ async _connectInternal(environment) {
467
+ const configs = await this.list();
468
+ const cache = {};
469
+ for (const cfg of configs) {
470
+ const chain = await cfg._buildChain(this._http);
471
+ cache[cfg.key] = resolveChain(chain, environment);
472
+ }
473
+ this._configCache = cache;
474
+ this._connected = true;
475
+ }
476
+ /**
477
+ * Read a resolved config value (prescriptive access).
478
+ *
479
+ * Requires {@link SmplClient.connect} to have been called.
480
+ *
481
+ * @param configKey - The config key to look up.
482
+ * @param itemKey - Optional specific item key. If omitted, returns all values.
483
+ * @param defaultValue - Default value if the key is missing.
484
+ *
485
+ * @throws {SmplNotConnectedError} If connect() has not been called.
486
+ */
487
+ getValue(configKey, itemKey, defaultValue) {
488
+ if (!this._connected) {
489
+ throw new SmplNotConnectedError("SmplClient is not connected. Call client.connect() first.");
490
+ }
491
+ const resolved = this._configCache[configKey];
492
+ if (resolved === void 0) {
493
+ return defaultValue ?? null;
494
+ }
495
+ if (itemKey === void 0) {
496
+ return { ...resolved };
497
+ }
498
+ return itemKey in resolved ? resolved[itemKey] : defaultValue ?? null;
499
+ }
467
500
  /**
468
501
  * Internal: PUT a full config update and return the updated model.
469
502
  *
@@ -1051,6 +1084,8 @@ var FlagsClient = class {
1051
1084
  // Shared WebSocket (set during connect)
1052
1085
  _wsManager = null;
1053
1086
  _ensureWs;
1087
+ /** @internal — set by SmplClient after construction. */
1088
+ _parent = null;
1054
1089
  /** @internal */
1055
1090
  constructor(apiKey, ensureWs, timeout) {
1056
1091
  this._apiKey = apiKey;
@@ -1289,8 +1324,9 @@ var FlagsClient = class {
1289
1324
  /**
1290
1325
  * Connect to an environment: fetch flag definitions, register on
1291
1326
  * shared WebSocket, enable local evaluation.
1327
+ * @internal — called by SmplClient.connect().
1292
1328
  */
1293
- async connect(environment, _options) {
1329
+ async _connectInternal(environment) {
1294
1330
  this._environment = environment;
1295
1331
  await this._fetchAllFlags();
1296
1332
  this._connected = true;
@@ -1377,6 +1413,9 @@ var FlagsClient = class {
1377
1413
  */
1378
1414
  async evaluate(key, options) {
1379
1415
  const evalDict = contextsToEvalDict(options.context);
1416
+ if (this._parent?._service && !("service" in evalDict)) {
1417
+ evalDict["service"] = { key: this._parent._service };
1418
+ }
1380
1419
  let flagDef = null;
1381
1420
  if (this._connected && key in this._flagStore) {
1382
1421
  flagDef = this._flagStore[key];
@@ -1400,7 +1439,7 @@ var FlagsClient = class {
1400
1439
  /** @internal */
1401
1440
  _evaluateHandle(key, defaultValue, context) {
1402
1441
  if (!this._connected) {
1403
- return defaultValue;
1442
+ throw new SmplNotConnectedError("SmplClient is not connected. Call client.connect() first.");
1404
1443
  }
1405
1444
  let evalDict;
1406
1445
  if (context !== null) {
@@ -1415,6 +1454,9 @@ var FlagsClient = class {
1415
1454
  } else {
1416
1455
  evalDict = {};
1417
1456
  }
1457
+ if (this._parent?._service && !("service" in evalDict)) {
1458
+ evalDict["service"] = { key: this._parent._service };
1459
+ }
1418
1460
  const ctxHash = hashContext(evalDict);
1419
1461
  const cacheKey = `${key}:${ctxHash}`;
1420
1462
  const [hit, cachedValue] = this._cache.get(cacheKey);
@@ -1741,6 +1783,7 @@ function resolveApiKey(explicit) {
1741
1783
 
1742
1784
  // src/client.ts
1743
1785
  var APP_BASE_URL2 = "https://app.smplkit.com";
1786
+ var NO_ENVIRONMENT_MESSAGE = "No environment provided. Set one of:\n 1. Pass environment to the constructor\n 2. Set the SMPLKIT_ENVIRONMENT environment variable";
1744
1787
  var SmplClient = class {
1745
1788
  /** Client for config management-plane operations. */
1746
1789
  config;
@@ -1748,12 +1791,66 @@ var SmplClient = class {
1748
1791
  flags;
1749
1792
  _wsManager = null;
1750
1793
  _apiKey;
1794
+ /** @internal */
1795
+ _environment;
1796
+ /** @internal */
1797
+ _service;
1798
+ _connected = false;
1799
+ _timeout;
1751
1800
  constructor(options = {}) {
1752
1801
  const apiKey = resolveApiKey(options.apiKey);
1753
1802
  this._apiKey = apiKey;
1754
- this.config = new ConfigClient(apiKey, options.timeout);
1755
- this.flags = new FlagsClient(apiKey, () => this._ensureWs(), options.timeout);
1803
+ const environment = options.environment || process.env.SMPLKIT_ENVIRONMENT;
1804
+ if (!environment) {
1805
+ throw new SmplError(NO_ENVIRONMENT_MESSAGE);
1806
+ }
1807
+ this._environment = environment;
1808
+ this._service = options.service || process.env.SMPLKIT_SERVICE || null;
1809
+ this._timeout = options.timeout ?? 3e4;
1810
+ this.config = new ConfigClient(apiKey, this._timeout);
1811
+ this.flags = new FlagsClient(apiKey, () => this._ensureWs(), this._timeout);
1756
1812
  this.config._getSharedWs = () => this._ensureWs();
1813
+ this.flags._parent = this;
1814
+ this.config._parent = this;
1815
+ }
1816
+ /**
1817
+ * Connect to the smplkit platform.
1818
+ *
1819
+ * Fetches initial flag and config data, opens the shared WebSocket,
1820
+ * and registers the service as a context instance (if provided).
1821
+ *
1822
+ * This method is idempotent — calling it multiple times is safe.
1823
+ */
1824
+ async connect() {
1825
+ if (this._connected) return;
1826
+ if (this._service) {
1827
+ await this._registerServiceContext();
1828
+ }
1829
+ await this.flags._connectInternal(this._environment);
1830
+ await this.config._connectInternal(this._environment);
1831
+ this._connected = true;
1832
+ }
1833
+ /** @internal */
1834
+ async _registerServiceContext() {
1835
+ try {
1836
+ await fetch(`${APP_BASE_URL2}/api/v1/contexts/bulk`, {
1837
+ method: "PUT",
1838
+ headers: {
1839
+ Authorization: `Bearer ${this._apiKey}`,
1840
+ "Content-Type": "application/json"
1841
+ },
1842
+ body: JSON.stringify({
1843
+ contexts: [
1844
+ {
1845
+ type: "service",
1846
+ key: this._service,
1847
+ attributes: { name: this._service }
1848
+ }
1849
+ ]
1850
+ })
1851
+ });
1852
+ } catch {
1853
+ }
1757
1854
  }
1758
1855
  /** Lazily create and start the shared WebSocket. @internal */
1759
1856
  _ensureWs() {
@@ -1772,6 +1869,215 @@ var SmplClient = class {
1772
1869
  }
1773
1870
  };
1774
1871
 
1872
+ // src/config/runtime.ts
1873
+ var ConfigRuntime = class {
1874
+ _cache;
1875
+ _chain;
1876
+ _fetchCount;
1877
+ _lastFetchAt;
1878
+ _closed = false;
1879
+ _listeners = [];
1880
+ _environment;
1881
+ _fetchChain;
1882
+ _sharedWs = null;
1883
+ /** @internal */
1884
+ constructor(options) {
1885
+ this._environment = options.environment;
1886
+ this._fetchChain = options.fetchChain;
1887
+ this._chain = options.chain;
1888
+ this._cache = resolveChain(options.chain, options.environment);
1889
+ this._fetchCount = options.chain.length;
1890
+ this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
1891
+ if (options.sharedWs) {
1892
+ this._sharedWs = options.sharedWs;
1893
+ this._sharedWs.on("config_changed", this._handleConfigChanged);
1894
+ this._sharedWs.on("config_deleted", this._handleConfigDeleted);
1895
+ }
1896
+ }
1897
+ // ---- Value access (synchronous, local cache) ----
1898
+ /**
1899
+ * Return the resolved value for `key`, or `defaultValue` if absent.
1900
+ *
1901
+ * @param key - The config key to look up.
1902
+ * @param defaultValue - Returned when the key is not present (default: null).
1903
+ */
1904
+ get(key, defaultValue = null) {
1905
+ return key in this._cache ? this._cache[key] : defaultValue;
1906
+ }
1907
+ /**
1908
+ * Return the value as a string, or `defaultValue` if absent or not a string.
1909
+ */
1910
+ getString(key, defaultValue = null) {
1911
+ const value = this._cache[key];
1912
+ return typeof value === "string" ? value : defaultValue;
1913
+ }
1914
+ /**
1915
+ * Return the value as a number, or `defaultValue` if absent or not a number.
1916
+ */
1917
+ getInt(key, defaultValue = null) {
1918
+ const value = this._cache[key];
1919
+ return typeof value === "number" ? value : defaultValue;
1920
+ }
1921
+ /**
1922
+ * Return the value as a boolean, or `defaultValue` if absent or not a boolean.
1923
+ */
1924
+ getBool(key, defaultValue = null) {
1925
+ const value = this._cache[key];
1926
+ return typeof value === "boolean" ? value : defaultValue;
1927
+ }
1928
+ /**
1929
+ * Return whether `key` is present in the resolved configuration.
1930
+ */
1931
+ exists(key) {
1932
+ return key in this._cache;
1933
+ }
1934
+ /**
1935
+ * Return a shallow copy of the full resolved configuration.
1936
+ */
1937
+ getAll() {
1938
+ return { ...this._cache };
1939
+ }
1940
+ // ---- Change listeners ----
1941
+ /**
1942
+ * Register a listener that fires when a config value changes.
1943
+ *
1944
+ * @param callback - Called with a {@link ConfigChangeEvent} on each change.
1945
+ * @param options.key - If provided, the listener fires only for this key.
1946
+ * If omitted, the listener fires for all changes.
1947
+ */
1948
+ onChange(callback, options) {
1949
+ this._listeners.push({
1950
+ callback,
1951
+ key: options?.key ?? null
1952
+ });
1953
+ }
1954
+ // ---- Diagnostics ----
1955
+ /**
1956
+ * Return diagnostic statistics for this runtime.
1957
+ */
1958
+ stats() {
1959
+ return {
1960
+ fetchCount: this._fetchCount,
1961
+ lastFetchAt: this._lastFetchAt
1962
+ };
1963
+ }
1964
+ /**
1965
+ * Return the current WebSocket connection status.
1966
+ */
1967
+ connectionStatus() {
1968
+ if (this._sharedWs) {
1969
+ return this._sharedWs.connectionStatus;
1970
+ }
1971
+ return "disconnected";
1972
+ }
1973
+ // ---- Lifecycle ----
1974
+ /**
1975
+ * Force a manual refresh of the cached configuration.
1976
+ *
1977
+ * Re-fetches the full config chain via HTTP, re-resolves values, updates
1978
+ * the local cache, and fires listeners for any detected changes.
1979
+ *
1980
+ * @throws {Error} If no `fetchChain` function was provided on construction.
1981
+ */
1982
+ async refresh() {
1983
+ if (!this._fetchChain) {
1984
+ throw new Error("No fetchChain function provided; cannot refresh.");
1985
+ }
1986
+ const newChain = await this._fetchChain();
1987
+ const oldCache = this._cache;
1988
+ this._chain = newChain;
1989
+ this._cache = resolveChain(newChain, this._environment);
1990
+ this._fetchCount += newChain.length;
1991
+ this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
1992
+ this._diffAndFire(oldCache, this._cache, "manual");
1993
+ }
1994
+ /**
1995
+ * Close the runtime connection.
1996
+ *
1997
+ * Unregisters from the shared WebSocket. Safe to call multiple times.
1998
+ */
1999
+ async close() {
2000
+ this._closed = true;
2001
+ if (this._sharedWs !== null) {
2002
+ this._sharedWs.off("config_changed", this._handleConfigChanged);
2003
+ this._sharedWs.off("config_deleted", this._handleConfigDeleted);
2004
+ this._sharedWs = null;
2005
+ }
2006
+ }
2007
+ /**
2008
+ * Async dispose support for `await using` (TypeScript 5.2+).
2009
+ */
2010
+ async [Symbol.asyncDispose]() {
2011
+ await this.close();
2012
+ }
2013
+ // ---- Shared WebSocket event handlers ----
2014
+ _handleConfigChanged = (data) => {
2015
+ if (this._closed) return;
2016
+ const configId = data.config_id;
2017
+ const changes = data.changes;
2018
+ if (configId && changes) {
2019
+ this._applyChanges(configId, changes);
2020
+ } else if (this._fetchChain) {
2021
+ void this._fetchChain().then((newChain) => {
2022
+ const oldCache = this._cache;
2023
+ this._chain = newChain;
2024
+ this._cache = resolveChain(newChain, this._environment);
2025
+ this._fetchCount += newChain.length;
2026
+ this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
2027
+ this._diffAndFire(oldCache, this._cache, "websocket");
2028
+ }).catch(() => {
2029
+ });
2030
+ }
2031
+ };
2032
+ _handleConfigDeleted = (_data) => {
2033
+ this._closed = true;
2034
+ void this.close();
2035
+ };
2036
+ _applyChanges(configId, changes) {
2037
+ const chainEntry = this._chain.find((c) => c.id === configId);
2038
+ if (!chainEntry) return;
2039
+ for (const change of changes) {
2040
+ const { key, new_value } = change;
2041
+ const envEntry = chainEntry.environments[this._environment] !== void 0 && chainEntry.environments[this._environment] !== null ? chainEntry.environments[this._environment] : null;
2042
+ const envValues = envEntry !== null && typeof envEntry === "object" ? envEntry.values ?? {} : null;
2043
+ if (new_value === null || new_value === void 0) {
2044
+ delete chainEntry.items[key];
2045
+ if (envValues) delete envValues[key];
2046
+ } else if (envValues && key in envValues) {
2047
+ envValues[key] = new_value;
2048
+ } else if (key in chainEntry.items) {
2049
+ chainEntry.items[key] = new_value;
2050
+ } else {
2051
+ chainEntry.items[key] = new_value;
2052
+ }
2053
+ }
2054
+ const oldCache = this._cache;
2055
+ this._cache = resolveChain(this._chain, this._environment);
2056
+ this._diffAndFire(oldCache, this._cache, "websocket");
2057
+ }
2058
+ _diffAndFire(oldCache, newCache, source) {
2059
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldCache), ...Object.keys(newCache)]);
2060
+ for (const key of allKeys) {
2061
+ const oldVal = key in oldCache ? oldCache[key] : null;
2062
+ const newVal = key in newCache ? newCache[key] : null;
2063
+ if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
2064
+ const event = { key, oldValue: oldVal, newValue: newVal, source };
2065
+ this._fireListeners(event);
2066
+ }
2067
+ }
2068
+ }
2069
+ _fireListeners(event) {
2070
+ for (const listener of this._listeners) {
2071
+ if (listener.key === null || listener.key === event.key) {
2072
+ try {
2073
+ listener.callback(event);
2074
+ } catch {
2075
+ }
2076
+ }
2077
+ }
2078
+ }
2079
+ };
2080
+
1775
2081
  // src/flags/types.ts
1776
2082
  var Context = class {
1777
2083
  type;
@@ -1855,6 +2161,7 @@ export {
1855
2161
  SmplConflictError,
1856
2162
  SmplConnectionError,
1857
2163
  SmplError,
2164
+ SmplNotConnectedError,
1858
2165
  SmplNotFoundError,
1859
2166
  SmplTimeoutError,
1860
2167
  SmplValidationError,