@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.cjs CHANGED
@@ -5,9 +5,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __esm = (fn, res) => function __init() {
9
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
- };
11
8
  var __export = (target, all) => {
12
9
  for (var name in all)
13
10
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -30,259 +27,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
27
  ));
31
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
29
 
33
- // src/config/resolve.ts
34
- function deepMerge(base, override) {
35
- const result = { ...base };
36
- for (const [key, value] of Object.entries(override)) {
37
- if (key in result && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) && typeof value === "object" && value !== null && !Array.isArray(value)) {
38
- result[key] = deepMerge(
39
- result[key],
40
- value
41
- );
42
- } else {
43
- result[key] = value;
44
- }
45
- }
46
- return result;
47
- }
48
- function resolveChain(chain, environment) {
49
- let accumulated = {};
50
- for (let i = chain.length - 1; i >= 0; i--) {
51
- const config = chain[i];
52
- const baseValues = config.items ?? {};
53
- const envEntry = (config.environments ?? {})[environment];
54
- const envValues = envEntry !== null && envEntry !== void 0 && typeof envEntry === "object" && !Array.isArray(envEntry) ? envEntry.values ?? {} : {};
55
- const configResolved = deepMerge(baseValues, envValues);
56
- accumulated = deepMerge(accumulated, configResolved);
57
- }
58
- return accumulated;
59
- }
60
- var init_resolve = __esm({
61
- "src/config/resolve.ts"() {
62
- "use strict";
63
- }
64
- });
65
-
66
- // src/config/runtime.ts
67
- var runtime_exports = {};
68
- __export(runtime_exports, {
69
- ConfigRuntime: () => ConfigRuntime
70
- });
71
- var ConfigRuntime;
72
- var init_runtime = __esm({
73
- "src/config/runtime.ts"() {
74
- "use strict";
75
- init_resolve();
76
- ConfigRuntime = class {
77
- _cache;
78
- _chain;
79
- _fetchCount;
80
- _lastFetchAt;
81
- _closed = false;
82
- _listeners = [];
83
- _environment;
84
- _fetchChain;
85
- _sharedWs = null;
86
- /** @internal */
87
- constructor(options) {
88
- this._environment = options.environment;
89
- this._fetchChain = options.fetchChain;
90
- this._chain = options.chain;
91
- this._cache = resolveChain(options.chain, options.environment);
92
- this._fetchCount = options.chain.length;
93
- this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
94
- if (options.sharedWs) {
95
- this._sharedWs = options.sharedWs;
96
- this._sharedWs.on("config_changed", this._handleConfigChanged);
97
- this._sharedWs.on("config_deleted", this._handleConfigDeleted);
98
- }
99
- }
100
- // ---- Value access (synchronous, local cache) ----
101
- /**
102
- * Return the resolved value for `key`, or `defaultValue` if absent.
103
- *
104
- * @param key - The config key to look up.
105
- * @param defaultValue - Returned when the key is not present (default: null).
106
- */
107
- get(key, defaultValue = null) {
108
- return key in this._cache ? this._cache[key] : defaultValue;
109
- }
110
- /**
111
- * Return the value as a string, or `defaultValue` if absent or not a string.
112
- */
113
- getString(key, defaultValue = null) {
114
- const value = this._cache[key];
115
- return typeof value === "string" ? value : defaultValue;
116
- }
117
- /**
118
- * Return the value as a number, or `defaultValue` if absent or not a number.
119
- */
120
- getInt(key, defaultValue = null) {
121
- const value = this._cache[key];
122
- return typeof value === "number" ? value : defaultValue;
123
- }
124
- /**
125
- * Return the value as a boolean, or `defaultValue` if absent or not a boolean.
126
- */
127
- getBool(key, defaultValue = null) {
128
- const value = this._cache[key];
129
- return typeof value === "boolean" ? value : defaultValue;
130
- }
131
- /**
132
- * Return whether `key` is present in the resolved configuration.
133
- */
134
- exists(key) {
135
- return key in this._cache;
136
- }
137
- /**
138
- * Return a shallow copy of the full resolved configuration.
139
- */
140
- getAll() {
141
- return { ...this._cache };
142
- }
143
- // ---- Change listeners ----
144
- /**
145
- * Register a listener that fires when a config value changes.
146
- *
147
- * @param callback - Called with a {@link ConfigChangeEvent} on each change.
148
- * @param options.key - If provided, the listener fires only for this key.
149
- * If omitted, the listener fires for all changes.
150
- */
151
- onChange(callback, options) {
152
- this._listeners.push({
153
- callback,
154
- key: options?.key ?? null
155
- });
156
- }
157
- // ---- Diagnostics ----
158
- /**
159
- * Return diagnostic statistics for this runtime.
160
- */
161
- stats() {
162
- return {
163
- fetchCount: this._fetchCount,
164
- lastFetchAt: this._lastFetchAt
165
- };
166
- }
167
- /**
168
- * Return the current WebSocket connection status.
169
- */
170
- connectionStatus() {
171
- if (this._sharedWs) {
172
- return this._sharedWs.connectionStatus;
173
- }
174
- return "disconnected";
175
- }
176
- // ---- Lifecycle ----
177
- /**
178
- * Force a manual refresh of the cached configuration.
179
- *
180
- * Re-fetches the full config chain via HTTP, re-resolves values, updates
181
- * the local cache, and fires listeners for any detected changes.
182
- *
183
- * @throws {Error} If no `fetchChain` function was provided on construction.
184
- */
185
- async refresh() {
186
- if (!this._fetchChain) {
187
- throw new Error("No fetchChain function provided; cannot refresh.");
188
- }
189
- const newChain = await this._fetchChain();
190
- const oldCache = this._cache;
191
- this._chain = newChain;
192
- this._cache = resolveChain(newChain, this._environment);
193
- this._fetchCount += newChain.length;
194
- this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
195
- this._diffAndFire(oldCache, this._cache, "manual");
196
- }
197
- /**
198
- * Close the runtime connection.
199
- *
200
- * Unregisters from the shared WebSocket. Safe to call multiple times.
201
- */
202
- async close() {
203
- this._closed = true;
204
- if (this._sharedWs !== null) {
205
- this._sharedWs.off("config_changed", this._handleConfigChanged);
206
- this._sharedWs.off("config_deleted", this._handleConfigDeleted);
207
- this._sharedWs = null;
208
- }
209
- }
210
- /**
211
- * Async dispose support for `await using` (TypeScript 5.2+).
212
- */
213
- async [Symbol.asyncDispose]() {
214
- await this.close();
215
- }
216
- // ---- Shared WebSocket event handlers ----
217
- _handleConfigChanged = (data) => {
218
- if (this._closed) return;
219
- const configId = data.config_id;
220
- const changes = data.changes;
221
- if (configId && changes) {
222
- this._applyChanges(configId, changes);
223
- } else if (this._fetchChain) {
224
- void this._fetchChain().then((newChain) => {
225
- const oldCache = this._cache;
226
- this._chain = newChain;
227
- this._cache = resolveChain(newChain, this._environment);
228
- this._fetchCount += newChain.length;
229
- this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
230
- this._diffAndFire(oldCache, this._cache, "websocket");
231
- }).catch(() => {
232
- });
233
- }
234
- };
235
- _handleConfigDeleted = (_data) => {
236
- this._closed = true;
237
- void this.close();
238
- };
239
- _applyChanges(configId, changes) {
240
- const chainEntry = this._chain.find((c) => c.id === configId);
241
- if (!chainEntry) return;
242
- for (const change of changes) {
243
- const { key, new_value } = change;
244
- const envEntry = chainEntry.environments[this._environment] !== void 0 && chainEntry.environments[this._environment] !== null ? chainEntry.environments[this._environment] : null;
245
- const envValues = envEntry !== null && typeof envEntry === "object" ? envEntry.values ?? {} : null;
246
- if (new_value === null || new_value === void 0) {
247
- delete chainEntry.items[key];
248
- if (envValues) delete envValues[key];
249
- } else if (envValues && key in envValues) {
250
- envValues[key] = new_value;
251
- } else if (key in chainEntry.items) {
252
- chainEntry.items[key] = new_value;
253
- } else {
254
- chainEntry.items[key] = new_value;
255
- }
256
- }
257
- const oldCache = this._cache;
258
- this._cache = resolveChain(this._chain, this._environment);
259
- this._diffAndFire(oldCache, this._cache, "websocket");
260
- }
261
- _diffAndFire(oldCache, newCache, source) {
262
- const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldCache), ...Object.keys(newCache)]);
263
- for (const key of allKeys) {
264
- const oldVal = key in oldCache ? oldCache[key] : null;
265
- const newVal = key in newCache ? newCache[key] : null;
266
- if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
267
- const event = { key, oldValue: oldVal, newValue: newVal, source };
268
- this._fireListeners(event);
269
- }
270
- }
271
- }
272
- _fireListeners(event) {
273
- for (const listener of this._listeners) {
274
- if (listener.key === null || listener.key === event.key) {
275
- try {
276
- listener.callback(event);
277
- } catch {
278
- }
279
- }
280
- }
281
- }
282
- };
283
- }
284
- });
285
-
286
30
  // src/index.ts
287
31
  var index_exports = {};
288
32
  __export(index_exports, {
@@ -304,6 +48,7 @@ __export(index_exports, {
304
48
  SmplConflictError: () => SmplConflictError,
305
49
  SmplConnectionError: () => SmplConnectionError,
306
50
  SmplError: () => SmplError,
51
+ SmplNotConnectedError: () => SmplNotConnectedError,
307
52
  SmplNotFoundError: () => SmplNotFoundError,
308
53
  SmplTimeoutError: () => SmplTimeoutError,
309
54
  SmplValidationError: () => SmplValidationError,
@@ -356,6 +101,13 @@ var SmplConflictError = class extends SmplError {
356
101
  Object.setPrototypeOf(this, new.target.prototype);
357
102
  }
358
103
  };
104
+ var SmplNotConnectedError = class extends SmplError {
105
+ constructor(message) {
106
+ super(message);
107
+ this.name = "SmplNotConnectedError";
108
+ Object.setPrototypeOf(this, new.target.prototype);
109
+ }
110
+ };
359
111
  var SmplValidationError = class extends SmplError {
360
112
  constructor(message, statusCode, responseBody) {
361
113
  super(message, statusCode ?? 422, responseBody);
@@ -364,6 +116,34 @@ var SmplValidationError = class extends SmplError {
364
116
  }
365
117
  };
366
118
 
119
+ // src/config/resolve.ts
120
+ function deepMerge(base, override) {
121
+ const result = { ...base };
122
+ for (const [key, value] of Object.entries(override)) {
123
+ if (key in result && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) && typeof value === "object" && value !== null && !Array.isArray(value)) {
124
+ result[key] = deepMerge(
125
+ result[key],
126
+ value
127
+ );
128
+ } else {
129
+ result[key] = value;
130
+ }
131
+ }
132
+ return result;
133
+ }
134
+ function resolveChain(chain, environment) {
135
+ let accumulated = {};
136
+ for (let i = chain.length - 1; i >= 0; i--) {
137
+ const config = chain[i];
138
+ const baseValues = config.items ?? {};
139
+ const envEntry = (config.environments ?? {})[environment];
140
+ const envValues = envEntry !== null && envEntry !== void 0 && typeof envEntry === "object" && !Array.isArray(envEntry) ? envEntry.values ?? {} : {};
141
+ const configResolved = deepMerge(baseValues, envValues);
142
+ accumulated = deepMerge(accumulated, configResolved);
143
+ }
144
+ return accumulated;
145
+ }
146
+
367
147
  // src/config/types.ts
368
148
  var Config = class {
369
149
  /** UUID of the config. */
@@ -490,46 +270,6 @@ var Config = class {
490
270
  await this.setValues(existing, environment);
491
271
  }
492
272
  }
493
- /**
494
- * Connect to this config for runtime value resolution.
495
- *
496
- * Eagerly fetches this config and its full parent chain, resolves values
497
- * for the given environment via deep merge, and returns a
498
- * {@link ConfigRuntime} with a fully populated local cache.
499
- *
500
- * A background WebSocket connection is started for real-time updates.
501
- * If the WebSocket fails to connect, the runtime operates in cache-only
502
- * mode and reconnects automatically.
503
- *
504
- * Supports both `await` and `await using` (TypeScript 5.2+)::
505
- *
506
- * ```typescript
507
- * // Simple await
508
- * const runtime = await config.connect("production");
509
- * try { ... } finally { await runtime.close(); }
510
- *
511
- * // await using (auto-close)
512
- * await using runtime = await config.connect("production");
513
- * ```
514
- *
515
- * @param environment - The environment to resolve for (e.g. `"production"`).
516
- * @param options.timeout - Milliseconds to wait for the initial fetch.
517
- */
518
- async connect(environment, options) {
519
- const { ConfigRuntime: ConfigRuntime2 } = await Promise.resolve().then(() => (init_runtime(), runtime_exports));
520
- const timeout = options?.timeout ?? 3e4;
521
- const chain = await this._buildChain(timeout);
522
- return new ConfigRuntime2({
523
- configKey: this.key,
524
- configId: this.id,
525
- environment,
526
- chain,
527
- apiKey: this._client._apiKey,
528
- baseUrl: this._client._baseUrl,
529
- fetchChain: () => this._buildChain(timeout),
530
- sharedWs: this._client._getSharedWs ? this._client._getSharedWs() : null
531
- });
532
- }
533
273
  /**
534
274
  * Walk the parent chain and return config data objects, child-to-root.
535
275
  * @internal
@@ -676,6 +416,10 @@ var ConfigClient = class {
676
416
  _http;
677
417
  /** @internal — returns the shared WebSocket for real-time updates. */
678
418
  _getSharedWs;
419
+ /** @internal — set by SmplClient after construction. */
420
+ _parent = null;
421
+ _configCache = {};
422
+ _connected = false;
679
423
  /** @internal */
680
424
  constructor(apiKey, timeout) {
681
425
  this._apiKey = apiKey;
@@ -773,6 +517,44 @@ var ConfigClient = class {
773
517
  wrapFetchError(err);
774
518
  }
775
519
  }
520
+ /**
521
+ * Fetch all configs, resolve values for the environment, and cache.
522
+ * @internal — called by SmplClient.connect().
523
+ */
524
+ async _connectInternal(environment) {
525
+ const configs = await this.list();
526
+ const cache = {};
527
+ for (const cfg of configs) {
528
+ const chain = await cfg._buildChain(this._http);
529
+ cache[cfg.key] = resolveChain(chain, environment);
530
+ }
531
+ this._configCache = cache;
532
+ this._connected = true;
533
+ }
534
+ /**
535
+ * Read a resolved config value (prescriptive access).
536
+ *
537
+ * Requires {@link SmplClient.connect} to have been called.
538
+ *
539
+ * @param configKey - The config key to look up.
540
+ * @param itemKey - Optional specific item key. If omitted, returns all values.
541
+ * @param defaultValue - Default value if the key is missing.
542
+ *
543
+ * @throws {SmplNotConnectedError} If connect() has not been called.
544
+ */
545
+ getValue(configKey, itemKey, defaultValue) {
546
+ if (!this._connected) {
547
+ throw new SmplNotConnectedError("SmplClient is not connected. Call client.connect() first.");
548
+ }
549
+ const resolved = this._configCache[configKey];
550
+ if (resolved === void 0) {
551
+ return defaultValue ?? null;
552
+ }
553
+ if (itemKey === void 0) {
554
+ return { ...resolved };
555
+ }
556
+ return itemKey in resolved ? resolved[itemKey] : defaultValue ?? null;
557
+ }
776
558
  /**
777
559
  * Internal: PUT a full config update and return the updated model.
778
560
  *
@@ -1360,6 +1142,8 @@ var FlagsClient = class {
1360
1142
  // Shared WebSocket (set during connect)
1361
1143
  _wsManager = null;
1362
1144
  _ensureWs;
1145
+ /** @internal — set by SmplClient after construction. */
1146
+ _parent = null;
1363
1147
  /** @internal */
1364
1148
  constructor(apiKey, ensureWs, timeout) {
1365
1149
  this._apiKey = apiKey;
@@ -1598,8 +1382,9 @@ var FlagsClient = class {
1598
1382
  /**
1599
1383
  * Connect to an environment: fetch flag definitions, register on
1600
1384
  * shared WebSocket, enable local evaluation.
1385
+ * @internal — called by SmplClient.connect().
1601
1386
  */
1602
- async connect(environment, _options) {
1387
+ async _connectInternal(environment) {
1603
1388
  this._environment = environment;
1604
1389
  await this._fetchAllFlags();
1605
1390
  this._connected = true;
@@ -1686,6 +1471,9 @@ var FlagsClient = class {
1686
1471
  */
1687
1472
  async evaluate(key, options) {
1688
1473
  const evalDict = contextsToEvalDict(options.context);
1474
+ if (this._parent?._service && !("service" in evalDict)) {
1475
+ evalDict["service"] = { key: this._parent._service };
1476
+ }
1689
1477
  let flagDef = null;
1690
1478
  if (this._connected && key in this._flagStore) {
1691
1479
  flagDef = this._flagStore[key];
@@ -1709,7 +1497,7 @@ var FlagsClient = class {
1709
1497
  /** @internal */
1710
1498
  _evaluateHandle(key, defaultValue, context) {
1711
1499
  if (!this._connected) {
1712
- return defaultValue;
1500
+ throw new SmplNotConnectedError("SmplClient is not connected. Call client.connect() first.");
1713
1501
  }
1714
1502
  let evalDict;
1715
1503
  if (context !== null) {
@@ -1724,6 +1512,9 @@ var FlagsClient = class {
1724
1512
  } else {
1725
1513
  evalDict = {};
1726
1514
  }
1515
+ if (this._parent?._service && !("service" in evalDict)) {
1516
+ evalDict["service"] = { key: this._parent._service };
1517
+ }
1727
1518
  const ctxHash = hashContext(evalDict);
1728
1519
  const cacheKey = `${key}:${ctxHash}`;
1729
1520
  const [hit, cachedValue] = this._cache.get(cacheKey);
@@ -2050,6 +1841,7 @@ function resolveApiKey(explicit) {
2050
1841
 
2051
1842
  // src/client.ts
2052
1843
  var APP_BASE_URL2 = "https://app.smplkit.com";
1844
+ 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";
2053
1845
  var SmplClient = class {
2054
1846
  /** Client for config management-plane operations. */
2055
1847
  config;
@@ -2057,12 +1849,66 @@ var SmplClient = class {
2057
1849
  flags;
2058
1850
  _wsManager = null;
2059
1851
  _apiKey;
1852
+ /** @internal */
1853
+ _environment;
1854
+ /** @internal */
1855
+ _service;
1856
+ _connected = false;
1857
+ _timeout;
2060
1858
  constructor(options = {}) {
2061
1859
  const apiKey = resolveApiKey(options.apiKey);
2062
1860
  this._apiKey = apiKey;
2063
- this.config = new ConfigClient(apiKey, options.timeout);
2064
- this.flags = new FlagsClient(apiKey, () => this._ensureWs(), options.timeout);
1861
+ const environment = options.environment || process.env.SMPLKIT_ENVIRONMENT;
1862
+ if (!environment) {
1863
+ throw new SmplError(NO_ENVIRONMENT_MESSAGE);
1864
+ }
1865
+ this._environment = environment;
1866
+ this._service = options.service || process.env.SMPLKIT_SERVICE || null;
1867
+ this._timeout = options.timeout ?? 3e4;
1868
+ this.config = new ConfigClient(apiKey, this._timeout);
1869
+ this.flags = new FlagsClient(apiKey, () => this._ensureWs(), this._timeout);
2065
1870
  this.config._getSharedWs = () => this._ensureWs();
1871
+ this.flags._parent = this;
1872
+ this.config._parent = this;
1873
+ }
1874
+ /**
1875
+ * Connect to the smplkit platform.
1876
+ *
1877
+ * Fetches initial flag and config data, opens the shared WebSocket,
1878
+ * and registers the service as a context instance (if provided).
1879
+ *
1880
+ * This method is idempotent — calling it multiple times is safe.
1881
+ */
1882
+ async connect() {
1883
+ if (this._connected) return;
1884
+ if (this._service) {
1885
+ await this._registerServiceContext();
1886
+ }
1887
+ await this.flags._connectInternal(this._environment);
1888
+ await this.config._connectInternal(this._environment);
1889
+ this._connected = true;
1890
+ }
1891
+ /** @internal */
1892
+ async _registerServiceContext() {
1893
+ try {
1894
+ await fetch(`${APP_BASE_URL2}/api/v1/contexts/bulk`, {
1895
+ method: "PUT",
1896
+ headers: {
1897
+ Authorization: `Bearer ${this._apiKey}`,
1898
+ "Content-Type": "application/json"
1899
+ },
1900
+ body: JSON.stringify({
1901
+ contexts: [
1902
+ {
1903
+ type: "service",
1904
+ key: this._service,
1905
+ attributes: { name: this._service }
1906
+ }
1907
+ ]
1908
+ })
1909
+ });
1910
+ } catch {
1911
+ }
2066
1912
  }
2067
1913
  /** Lazily create and start the shared WebSocket. @internal */
2068
1914
  _ensureWs() {
@@ -2081,8 +1927,214 @@ var SmplClient = class {
2081
1927
  }
2082
1928
  };
2083
1929
 
2084
- // src/index.ts
2085
- init_runtime();
1930
+ // src/config/runtime.ts
1931
+ var ConfigRuntime = class {
1932
+ _cache;
1933
+ _chain;
1934
+ _fetchCount;
1935
+ _lastFetchAt;
1936
+ _closed = false;
1937
+ _listeners = [];
1938
+ _environment;
1939
+ _fetchChain;
1940
+ _sharedWs = null;
1941
+ /** @internal */
1942
+ constructor(options) {
1943
+ this._environment = options.environment;
1944
+ this._fetchChain = options.fetchChain;
1945
+ this._chain = options.chain;
1946
+ this._cache = resolveChain(options.chain, options.environment);
1947
+ this._fetchCount = options.chain.length;
1948
+ this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
1949
+ if (options.sharedWs) {
1950
+ this._sharedWs = options.sharedWs;
1951
+ this._sharedWs.on("config_changed", this._handleConfigChanged);
1952
+ this._sharedWs.on("config_deleted", this._handleConfigDeleted);
1953
+ }
1954
+ }
1955
+ // ---- Value access (synchronous, local cache) ----
1956
+ /**
1957
+ * Return the resolved value for `key`, or `defaultValue` if absent.
1958
+ *
1959
+ * @param key - The config key to look up.
1960
+ * @param defaultValue - Returned when the key is not present (default: null).
1961
+ */
1962
+ get(key, defaultValue = null) {
1963
+ return key in this._cache ? this._cache[key] : defaultValue;
1964
+ }
1965
+ /**
1966
+ * Return the value as a string, or `defaultValue` if absent or not a string.
1967
+ */
1968
+ getString(key, defaultValue = null) {
1969
+ const value = this._cache[key];
1970
+ return typeof value === "string" ? value : defaultValue;
1971
+ }
1972
+ /**
1973
+ * Return the value as a number, or `defaultValue` if absent or not a number.
1974
+ */
1975
+ getInt(key, defaultValue = null) {
1976
+ const value = this._cache[key];
1977
+ return typeof value === "number" ? value : defaultValue;
1978
+ }
1979
+ /**
1980
+ * Return the value as a boolean, or `defaultValue` if absent or not a boolean.
1981
+ */
1982
+ getBool(key, defaultValue = null) {
1983
+ const value = this._cache[key];
1984
+ return typeof value === "boolean" ? value : defaultValue;
1985
+ }
1986
+ /**
1987
+ * Return whether `key` is present in the resolved configuration.
1988
+ */
1989
+ exists(key) {
1990
+ return key in this._cache;
1991
+ }
1992
+ /**
1993
+ * Return a shallow copy of the full resolved configuration.
1994
+ */
1995
+ getAll() {
1996
+ return { ...this._cache };
1997
+ }
1998
+ // ---- Change listeners ----
1999
+ /**
2000
+ * Register a listener that fires when a config value changes.
2001
+ *
2002
+ * @param callback - Called with a {@link ConfigChangeEvent} on each change.
2003
+ * @param options.key - If provided, the listener fires only for this key.
2004
+ * If omitted, the listener fires for all changes.
2005
+ */
2006
+ onChange(callback, options) {
2007
+ this._listeners.push({
2008
+ callback,
2009
+ key: options?.key ?? null
2010
+ });
2011
+ }
2012
+ // ---- Diagnostics ----
2013
+ /**
2014
+ * Return diagnostic statistics for this runtime.
2015
+ */
2016
+ stats() {
2017
+ return {
2018
+ fetchCount: this._fetchCount,
2019
+ lastFetchAt: this._lastFetchAt
2020
+ };
2021
+ }
2022
+ /**
2023
+ * Return the current WebSocket connection status.
2024
+ */
2025
+ connectionStatus() {
2026
+ if (this._sharedWs) {
2027
+ return this._sharedWs.connectionStatus;
2028
+ }
2029
+ return "disconnected";
2030
+ }
2031
+ // ---- Lifecycle ----
2032
+ /**
2033
+ * Force a manual refresh of the cached configuration.
2034
+ *
2035
+ * Re-fetches the full config chain via HTTP, re-resolves values, updates
2036
+ * the local cache, and fires listeners for any detected changes.
2037
+ *
2038
+ * @throws {Error} If no `fetchChain` function was provided on construction.
2039
+ */
2040
+ async refresh() {
2041
+ if (!this._fetchChain) {
2042
+ throw new Error("No fetchChain function provided; cannot refresh.");
2043
+ }
2044
+ const newChain = await this._fetchChain();
2045
+ const oldCache = this._cache;
2046
+ this._chain = newChain;
2047
+ this._cache = resolveChain(newChain, this._environment);
2048
+ this._fetchCount += newChain.length;
2049
+ this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
2050
+ this._diffAndFire(oldCache, this._cache, "manual");
2051
+ }
2052
+ /**
2053
+ * Close the runtime connection.
2054
+ *
2055
+ * Unregisters from the shared WebSocket. Safe to call multiple times.
2056
+ */
2057
+ async close() {
2058
+ this._closed = true;
2059
+ if (this._sharedWs !== null) {
2060
+ this._sharedWs.off("config_changed", this._handleConfigChanged);
2061
+ this._sharedWs.off("config_deleted", this._handleConfigDeleted);
2062
+ this._sharedWs = null;
2063
+ }
2064
+ }
2065
+ /**
2066
+ * Async dispose support for `await using` (TypeScript 5.2+).
2067
+ */
2068
+ async [Symbol.asyncDispose]() {
2069
+ await this.close();
2070
+ }
2071
+ // ---- Shared WebSocket event handlers ----
2072
+ _handleConfigChanged = (data) => {
2073
+ if (this._closed) return;
2074
+ const configId = data.config_id;
2075
+ const changes = data.changes;
2076
+ if (configId && changes) {
2077
+ this._applyChanges(configId, changes);
2078
+ } else if (this._fetchChain) {
2079
+ void this._fetchChain().then((newChain) => {
2080
+ const oldCache = this._cache;
2081
+ this._chain = newChain;
2082
+ this._cache = resolveChain(newChain, this._environment);
2083
+ this._fetchCount += newChain.length;
2084
+ this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
2085
+ this._diffAndFire(oldCache, this._cache, "websocket");
2086
+ }).catch(() => {
2087
+ });
2088
+ }
2089
+ };
2090
+ _handleConfigDeleted = (_data) => {
2091
+ this._closed = true;
2092
+ void this.close();
2093
+ };
2094
+ _applyChanges(configId, changes) {
2095
+ const chainEntry = this._chain.find((c) => c.id === configId);
2096
+ if (!chainEntry) return;
2097
+ for (const change of changes) {
2098
+ const { key, new_value } = change;
2099
+ const envEntry = chainEntry.environments[this._environment] !== void 0 && chainEntry.environments[this._environment] !== null ? chainEntry.environments[this._environment] : null;
2100
+ const envValues = envEntry !== null && typeof envEntry === "object" ? envEntry.values ?? {} : null;
2101
+ if (new_value === null || new_value === void 0) {
2102
+ delete chainEntry.items[key];
2103
+ if (envValues) delete envValues[key];
2104
+ } else if (envValues && key in envValues) {
2105
+ envValues[key] = new_value;
2106
+ } else if (key in chainEntry.items) {
2107
+ chainEntry.items[key] = new_value;
2108
+ } else {
2109
+ chainEntry.items[key] = new_value;
2110
+ }
2111
+ }
2112
+ const oldCache = this._cache;
2113
+ this._cache = resolveChain(this._chain, this._environment);
2114
+ this._diffAndFire(oldCache, this._cache, "websocket");
2115
+ }
2116
+ _diffAndFire(oldCache, newCache, source) {
2117
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldCache), ...Object.keys(newCache)]);
2118
+ for (const key of allKeys) {
2119
+ const oldVal = key in oldCache ? oldCache[key] : null;
2120
+ const newVal = key in newCache ? newCache[key] : null;
2121
+ if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
2122
+ const event = { key, oldValue: oldVal, newValue: newVal, source };
2123
+ this._fireListeners(event);
2124
+ }
2125
+ }
2126
+ }
2127
+ _fireListeners(event) {
2128
+ for (const listener of this._listeners) {
2129
+ if (listener.key === null || listener.key === event.key) {
2130
+ try {
2131
+ listener.callback(event);
2132
+ } catch {
2133
+ }
2134
+ }
2135
+ }
2136
+ }
2137
+ };
2086
2138
 
2087
2139
  // src/flags/types.ts
2088
2140
  var Context = class {
@@ -2168,6 +2220,7 @@ var Rule = class {
2168
2220
  SmplConflictError,
2169
2221
  SmplConnectionError,
2170
2222
  SmplError,
2223
+ SmplNotConnectedError,
2171
2224
  SmplNotFoundError,
2172
2225
  SmplTimeoutError,
2173
2226
  SmplValidationError,