@smplkit/sdk 1.0.0 → 1.1.0
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 +3 -4
- package/dist/chunk-PZD5PSQY.js +317 -0
- package/dist/chunk-PZD5PSQY.js.map +1 -0
- package/dist/index.cjs +874 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +476 -0
- package/dist/index.d.ts +299 -88
- package/dist/index.js +372 -236
- package/dist/index.js.map +1 -1
- package/dist/runtime-CCRTBKED.js +7 -0
- package/dist/runtime-CCRTBKED.js.map +1 -0
- package/package.json +7 -1
- package/dist/index.d.mts +0 -265
- package/dist/index.mjs +0 -329
- package/dist/index.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -5,13 +5,13 @@ Official TypeScript SDK for the [smplkit](https://docs.smplkit.com) platform.
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install smplkit
|
|
8
|
+
npm install @smplkit/sdk
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
13
|
```typescript
|
|
14
|
-
import { SmplkitClient } from "smplkit";
|
|
14
|
+
import { SmplkitClient } from "@smplkit/sdk";
|
|
15
15
|
|
|
16
16
|
const client = new SmplkitClient({ apiKey: "sk_api_..." });
|
|
17
17
|
|
|
@@ -39,7 +39,6 @@ await client.config.delete(newConfig.id);
|
|
|
39
39
|
```typescript
|
|
40
40
|
const client = new SmplkitClient({
|
|
41
41
|
apiKey: "sk_api_...", // Required
|
|
42
|
-
baseUrl: "https://config.smplkit.com", // Optional (default shown)
|
|
43
42
|
timeout: 30000, // Optional, in milliseconds (default: 30000)
|
|
44
43
|
});
|
|
45
44
|
```
|
|
@@ -56,7 +55,7 @@ import {
|
|
|
56
55
|
SmplValidationError,
|
|
57
56
|
SmplConnectionError,
|
|
58
57
|
SmplTimeoutError,
|
|
59
|
-
} from "smplkit";
|
|
58
|
+
} from "@smplkit/sdk";
|
|
60
59
|
|
|
61
60
|
try {
|
|
62
61
|
const config = await client.config.get({ key: "nonexistent" });
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// src/config/runtime.ts
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
|
|
4
|
+
// src/config/resolve.ts
|
|
5
|
+
function deepMerge(base, override) {
|
|
6
|
+
const result = { ...base };
|
|
7
|
+
for (const [key, value] of Object.entries(override)) {
|
|
8
|
+
if (key in result && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
9
|
+
result[key] = deepMerge(
|
|
10
|
+
result[key],
|
|
11
|
+
value
|
|
12
|
+
);
|
|
13
|
+
} else {
|
|
14
|
+
result[key] = value;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
function resolveChain(chain, environment) {
|
|
20
|
+
let accumulated = {};
|
|
21
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
22
|
+
const config = chain[i];
|
|
23
|
+
const baseValues = config.values ?? {};
|
|
24
|
+
const envEntry = (config.environments ?? {})[environment];
|
|
25
|
+
const envValues = envEntry !== null && envEntry !== void 0 && typeof envEntry === "object" && !Array.isArray(envEntry) ? envEntry.values ?? {} : {};
|
|
26
|
+
const configResolved = deepMerge(baseValues, envValues);
|
|
27
|
+
accumulated = deepMerge(accumulated, configResolved);
|
|
28
|
+
}
|
|
29
|
+
return accumulated;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/config/runtime.ts
|
|
33
|
+
var BACKOFF_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 32e3, 6e4];
|
|
34
|
+
var ConfigRuntime = class {
|
|
35
|
+
_cache;
|
|
36
|
+
_chain;
|
|
37
|
+
_fetchCount;
|
|
38
|
+
_lastFetchAt;
|
|
39
|
+
_closed = false;
|
|
40
|
+
_wsStatus = "disconnected";
|
|
41
|
+
_ws = null;
|
|
42
|
+
_reconnectTimer = null;
|
|
43
|
+
_backoffIndex = 0;
|
|
44
|
+
_listeners = [];
|
|
45
|
+
_configId;
|
|
46
|
+
_environment;
|
|
47
|
+
_apiKey;
|
|
48
|
+
_baseUrl;
|
|
49
|
+
_fetchChain;
|
|
50
|
+
/** @internal */
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this._configId = options.configId;
|
|
53
|
+
this._environment = options.environment;
|
|
54
|
+
this._apiKey = options.apiKey;
|
|
55
|
+
this._baseUrl = options.baseUrl;
|
|
56
|
+
this._fetchChain = options.fetchChain;
|
|
57
|
+
this._chain = options.chain;
|
|
58
|
+
this._cache = resolveChain(options.chain, options.environment);
|
|
59
|
+
this._fetchCount = options.chain.length;
|
|
60
|
+
this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
61
|
+
this._connectWebSocket();
|
|
62
|
+
}
|
|
63
|
+
// ---- Value access (synchronous, local cache) ----
|
|
64
|
+
/**
|
|
65
|
+
* Return the resolved value for `key`, or `defaultValue` if absent.
|
|
66
|
+
*
|
|
67
|
+
* @param key - The config key to look up.
|
|
68
|
+
* @param defaultValue - Returned when the key is not present (default: null).
|
|
69
|
+
*/
|
|
70
|
+
get(key, defaultValue = null) {
|
|
71
|
+
return key in this._cache ? this._cache[key] : defaultValue;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Return the value as a string, or `defaultValue` if absent or not a string.
|
|
75
|
+
*/
|
|
76
|
+
getString(key, defaultValue = null) {
|
|
77
|
+
const value = this._cache[key];
|
|
78
|
+
return typeof value === "string" ? value : defaultValue;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Return the value as a number, or `defaultValue` if absent or not a number.
|
|
82
|
+
*/
|
|
83
|
+
getNumber(key, defaultValue = null) {
|
|
84
|
+
const value = this._cache[key];
|
|
85
|
+
return typeof value === "number" ? value : defaultValue;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Return the value as a boolean, or `defaultValue` if absent or not a boolean.
|
|
89
|
+
*/
|
|
90
|
+
getBool(key, defaultValue = null) {
|
|
91
|
+
const value = this._cache[key];
|
|
92
|
+
return typeof value === "boolean" ? value : defaultValue;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Return whether `key` is present in the resolved configuration.
|
|
96
|
+
*/
|
|
97
|
+
exists(key) {
|
|
98
|
+
return key in this._cache;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Return a shallow copy of the full resolved configuration.
|
|
102
|
+
*/
|
|
103
|
+
getAll() {
|
|
104
|
+
return { ...this._cache };
|
|
105
|
+
}
|
|
106
|
+
// ---- Change listeners ----
|
|
107
|
+
/**
|
|
108
|
+
* Register a listener that fires when a config value changes.
|
|
109
|
+
*
|
|
110
|
+
* @param callback - Called with a {@link ConfigChangeEvent} on each change.
|
|
111
|
+
* @param options.key - If provided, the listener fires only for this key.
|
|
112
|
+
* If omitted, the listener fires for all changes.
|
|
113
|
+
*/
|
|
114
|
+
onChange(callback, options) {
|
|
115
|
+
this._listeners.push({
|
|
116
|
+
callback,
|
|
117
|
+
key: options?.key ?? null
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// ---- Diagnostics ----
|
|
121
|
+
/**
|
|
122
|
+
* Return diagnostic statistics for this runtime.
|
|
123
|
+
*/
|
|
124
|
+
stats() {
|
|
125
|
+
return {
|
|
126
|
+
fetchCount: this._fetchCount,
|
|
127
|
+
lastFetchAt: this._lastFetchAt
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Return the current WebSocket connection status.
|
|
132
|
+
*/
|
|
133
|
+
connectionStatus() {
|
|
134
|
+
return this._wsStatus;
|
|
135
|
+
}
|
|
136
|
+
// ---- Lifecycle ----
|
|
137
|
+
/**
|
|
138
|
+
* Force a manual refresh of the cached configuration.
|
|
139
|
+
*
|
|
140
|
+
* Re-fetches the full config chain via HTTP, re-resolves values, updates
|
|
141
|
+
* the local cache, and fires listeners for any detected changes.
|
|
142
|
+
*
|
|
143
|
+
* @throws {Error} If no `fetchChain` function was provided on construction.
|
|
144
|
+
*/
|
|
145
|
+
async refresh() {
|
|
146
|
+
if (!this._fetchChain) {
|
|
147
|
+
throw new Error("No fetchChain function provided; cannot refresh.");
|
|
148
|
+
}
|
|
149
|
+
const newChain = await this._fetchChain();
|
|
150
|
+
const oldCache = this._cache;
|
|
151
|
+
this._chain = newChain;
|
|
152
|
+
this._cache = resolveChain(newChain, this._environment);
|
|
153
|
+
this._fetchCount += newChain.length;
|
|
154
|
+
this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
155
|
+
this._diffAndFire(oldCache, this._cache, "manual");
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Close the runtime connection.
|
|
159
|
+
*
|
|
160
|
+
* Shuts down the WebSocket and cancels any pending reconnect timer.
|
|
161
|
+
* Safe to call multiple times.
|
|
162
|
+
*/
|
|
163
|
+
async close() {
|
|
164
|
+
this._closed = true;
|
|
165
|
+
this._wsStatus = "disconnected";
|
|
166
|
+
if (this._reconnectTimer !== null) {
|
|
167
|
+
clearTimeout(this._reconnectTimer);
|
|
168
|
+
this._reconnectTimer = null;
|
|
169
|
+
}
|
|
170
|
+
if (this._ws !== null) {
|
|
171
|
+
this._ws.close();
|
|
172
|
+
this._ws = null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Async dispose support for `await using` (TypeScript 5.2+).
|
|
177
|
+
*/
|
|
178
|
+
async [Symbol.asyncDispose]() {
|
|
179
|
+
await this.close();
|
|
180
|
+
}
|
|
181
|
+
// ---- WebSocket internals ----
|
|
182
|
+
_buildWsUrl() {
|
|
183
|
+
let url = this._baseUrl;
|
|
184
|
+
if (url.startsWith("https://")) {
|
|
185
|
+
url = "wss://" + url.slice("https://".length);
|
|
186
|
+
} else if (url.startsWith("http://")) {
|
|
187
|
+
url = "ws://" + url.slice("http://".length);
|
|
188
|
+
} else {
|
|
189
|
+
url = "wss://" + url;
|
|
190
|
+
}
|
|
191
|
+
url = url.replace(/\/$/, "");
|
|
192
|
+
return `${url}/api/ws/v1/configs?api_key=${this._apiKey}`;
|
|
193
|
+
}
|
|
194
|
+
_connectWebSocket() {
|
|
195
|
+
if (this._closed) return;
|
|
196
|
+
this._wsStatus = "connecting";
|
|
197
|
+
const wsUrl = this._buildWsUrl();
|
|
198
|
+
try {
|
|
199
|
+
const ws = new WebSocket(wsUrl);
|
|
200
|
+
this._ws = ws;
|
|
201
|
+
ws.on("open", () => {
|
|
202
|
+
if (this._closed) {
|
|
203
|
+
ws.close();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this._backoffIndex = 0;
|
|
207
|
+
this._wsStatus = "connected";
|
|
208
|
+
ws.send(
|
|
209
|
+
JSON.stringify({
|
|
210
|
+
type: "subscribe",
|
|
211
|
+
config_id: this._configId,
|
|
212
|
+
environment: this._environment
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
ws.on("message", (data) => {
|
|
217
|
+
try {
|
|
218
|
+
const msg = JSON.parse(String(data));
|
|
219
|
+
this._handleMessage(msg);
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
ws.on("close", () => {
|
|
224
|
+
if (!this._closed) {
|
|
225
|
+
this._wsStatus = "disconnected";
|
|
226
|
+
this._scheduleReconnect();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
ws.on("error", () => {
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
if (!this._closed) {
|
|
233
|
+
this._scheduleReconnect();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
_scheduleReconnect() {
|
|
238
|
+
if (this._closed) return;
|
|
239
|
+
const delay = BACKOFF_MS[Math.min(this._backoffIndex, BACKOFF_MS.length - 1)];
|
|
240
|
+
this._backoffIndex++;
|
|
241
|
+
this._wsStatus = "connecting";
|
|
242
|
+
this._reconnectTimer = setTimeout(() => {
|
|
243
|
+
this._reconnectTimer = null;
|
|
244
|
+
if (this._fetchChain) {
|
|
245
|
+
this._fetchChain().then((newChain) => {
|
|
246
|
+
const oldCache = this._cache;
|
|
247
|
+
this._chain = newChain;
|
|
248
|
+
this._cache = resolveChain(newChain, this._environment);
|
|
249
|
+
this._fetchCount += newChain.length;
|
|
250
|
+
this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
251
|
+
this._diffAndFire(oldCache, this._cache, "manual");
|
|
252
|
+
}).catch(() => {
|
|
253
|
+
}).finally(() => {
|
|
254
|
+
this._connectWebSocket();
|
|
255
|
+
});
|
|
256
|
+
} else {
|
|
257
|
+
this._connectWebSocket();
|
|
258
|
+
}
|
|
259
|
+
}, delay);
|
|
260
|
+
}
|
|
261
|
+
_handleMessage(msg) {
|
|
262
|
+
if (msg.type === "config_changed") {
|
|
263
|
+
this._applyChanges(msg.config_id, msg.changes);
|
|
264
|
+
} else if (msg.type === "config_deleted") {
|
|
265
|
+
this._closed = true;
|
|
266
|
+
void this.close();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
_applyChanges(configId, changes) {
|
|
270
|
+
const chainEntry = this._chain.find((c) => c.id === configId);
|
|
271
|
+
if (!chainEntry) return;
|
|
272
|
+
for (const change of changes) {
|
|
273
|
+
const { key, new_value } = change;
|
|
274
|
+
const envEntry = chainEntry.environments[this._environment] !== void 0 && chainEntry.environments[this._environment] !== null ? chainEntry.environments[this._environment] : null;
|
|
275
|
+
const envValues = envEntry !== null && typeof envEntry === "object" ? envEntry.values ?? {} : null;
|
|
276
|
+
if (new_value === null || new_value === void 0) {
|
|
277
|
+
delete chainEntry.values[key];
|
|
278
|
+
if (envValues) delete envValues[key];
|
|
279
|
+
} else if (envValues && key in envValues) {
|
|
280
|
+
envValues[key] = new_value;
|
|
281
|
+
} else if (key in chainEntry.values) {
|
|
282
|
+
chainEntry.values[key] = new_value;
|
|
283
|
+
} else {
|
|
284
|
+
chainEntry.values[key] = new_value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const oldCache = this._cache;
|
|
288
|
+
this._cache = resolveChain(this._chain, this._environment);
|
|
289
|
+
this._diffAndFire(oldCache, this._cache, "websocket");
|
|
290
|
+
}
|
|
291
|
+
_diffAndFire(oldCache, newCache, source) {
|
|
292
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldCache), ...Object.keys(newCache)]);
|
|
293
|
+
for (const key of allKeys) {
|
|
294
|
+
const oldVal = key in oldCache ? oldCache[key] : null;
|
|
295
|
+
const newVal = key in newCache ? newCache[key] : null;
|
|
296
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
297
|
+
const event = { key, oldValue: oldVal, newValue: newVal, source };
|
|
298
|
+
this._fireListeners(event);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
_fireListeners(event) {
|
|
303
|
+
for (const listener of this._listeners) {
|
|
304
|
+
if (listener.key === null || listener.key === event.key) {
|
|
305
|
+
try {
|
|
306
|
+
listener.callback(event);
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export {
|
|
315
|
+
ConfigRuntime
|
|
316
|
+
};
|
|
317
|
+
//# sourceMappingURL=chunk-PZD5PSQY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config/runtime.ts","../src/config/resolve.ts"],"sourcesContent":["/**\n * ConfigRuntime — runtime-plane value resolution with WebSocket updates.\n *\n * Holds a fully resolved local cache of config values for a specific\n * environment. All value-access methods are synchronous (local reads);\n * only {@link refresh} and {@link close} are async.\n *\n * A background WebSocket connection is maintained for real-time updates.\n * If the WebSocket fails, the runtime operates in cache-only mode and\n * reconnects automatically with exponential backoff.\n */\n\nimport WebSocket from \"ws\";\nimport { resolveChain } from \"./resolve.js\";\nimport type { ChainConfig } from \"./resolve.js\";\nimport type { ConfigChangeEvent, ConfigStats, ConnectionStatus } from \"./runtime-types.js\";\n\n/** @internal */\ninterface ChangeListener {\n callback: (event: ConfigChangeEvent) => void;\n key: string | null;\n}\n\n/** @internal */\ninterface WsConfigChangedMessage {\n type: \"config_changed\";\n config_id: string;\n changes: Array<{\n key: string;\n old_value: unknown;\n new_value: unknown;\n }>;\n}\n\n/** @internal */\ninterface WsConfigDeletedMessage {\n type: \"config_deleted\";\n config_id: string;\n}\n\ntype WsMessage =\n | { type: \"subscribed\"; config_id: string; environment: string }\n | { type: \"error\"; message: string }\n | WsConfigChangedMessage\n | WsConfigDeletedMessage;\n\n/** @internal */\nconst BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 32000, 60000];\n\n/** @internal Options for constructing a ConfigRuntime. */\nexport interface ConfigRuntimeOptions {\n configKey: string;\n configId: string;\n environment: string;\n chain: ChainConfig[];\n apiKey: string;\n baseUrl: string;\n fetchChain: (() => Promise<ChainConfig[]>) | null;\n}\n\n/**\n * Runtime configuration handle for a specific environment.\n *\n * Obtained by calling {@link Config.connect}. All value-access methods\n * are synchronous and served entirely from a local in-process cache.\n * The cache is populated eagerly on construction and kept current via\n * a background WebSocket connection.\n */\nexport class ConfigRuntime {\n private _cache: Record<string, unknown>;\n private _chain: ChainConfig[];\n private _fetchCount: number;\n private _lastFetchAt: string | null;\n private _closed = false;\n private _wsStatus: ConnectionStatus = \"disconnected\";\n private _ws: InstanceType<typeof WebSocket> | null = null;\n private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private _backoffIndex = 0;\n private _listeners: ChangeListener[] = [];\n\n private readonly _configId: string;\n private readonly _environment: string;\n private readonly _apiKey: string;\n private readonly _baseUrl: string;\n private readonly _fetchChain: (() => Promise<ChainConfig[]>) | null;\n\n /** @internal */\n constructor(options: ConfigRuntimeOptions) {\n this._configId = options.configId;\n this._environment = options.environment;\n this._apiKey = options.apiKey;\n this._baseUrl = options.baseUrl;\n this._fetchChain = options.fetchChain;\n this._chain = options.chain;\n this._cache = resolveChain(options.chain, options.environment);\n this._fetchCount = options.chain.length;\n this._lastFetchAt = new Date().toISOString();\n\n // Start WebSocket in background — non-blocking\n this._connectWebSocket();\n }\n\n // ---- Value access (synchronous, local cache) ----\n\n /**\n * Return the resolved value for `key`, or `defaultValue` if absent.\n *\n * @param key - The config key to look up.\n * @param defaultValue - Returned when the key is not present (default: null).\n */\n get(key: string, defaultValue: unknown = null): unknown {\n return key in this._cache ? this._cache[key] : defaultValue;\n }\n\n /**\n * Return the value as a string, or `defaultValue` if absent or not a string.\n */\n getString(key: string, defaultValue: string | null = null): string | null {\n const value = this._cache[key];\n return typeof value === \"string\" ? value : defaultValue;\n }\n\n /**\n * Return the value as a number, or `defaultValue` if absent or not a number.\n */\n getNumber(key: string, defaultValue: number | null = null): number | null {\n const value = this._cache[key];\n return typeof value === \"number\" ? value : defaultValue;\n }\n\n /**\n * Return the value as a boolean, or `defaultValue` if absent or not a boolean.\n */\n getBool(key: string, defaultValue: boolean | null = null): boolean | null {\n const value = this._cache[key];\n return typeof value === \"boolean\" ? value : defaultValue;\n }\n\n /**\n * Return whether `key` is present in the resolved configuration.\n */\n exists(key: string): boolean {\n return key in this._cache;\n }\n\n /**\n * Return a shallow copy of the full resolved configuration.\n */\n getAll(): Record<string, unknown> {\n return { ...this._cache };\n }\n\n // ---- Change listeners ----\n\n /**\n * Register a listener that fires when a config value changes.\n *\n * @param callback - Called with a {@link ConfigChangeEvent} on each change.\n * @param options.key - If provided, the listener fires only for this key.\n * If omitted, the listener fires for all changes.\n */\n onChange(callback: (event: ConfigChangeEvent) => void, options?: { key?: string }): void {\n this._listeners.push({\n callback,\n key: options?.key ?? null,\n });\n }\n\n // ---- Diagnostics ----\n\n /**\n * Return diagnostic statistics for this runtime.\n */\n stats(): ConfigStats {\n return {\n fetchCount: this._fetchCount,\n lastFetchAt: this._lastFetchAt,\n };\n }\n\n /**\n * Return the current WebSocket connection status.\n */\n connectionStatus(): ConnectionStatus {\n return this._wsStatus;\n }\n\n // ---- Lifecycle ----\n\n /**\n * Force a manual refresh of the cached configuration.\n *\n * Re-fetches the full config chain via HTTP, re-resolves values, updates\n * the local cache, and fires listeners for any detected changes.\n *\n * @throws {Error} If no `fetchChain` function was provided on construction.\n */\n async refresh(): Promise<void> {\n if (!this._fetchChain) {\n throw new Error(\"No fetchChain function provided; cannot refresh.\");\n }\n\n const newChain = await this._fetchChain();\n const oldCache = this._cache;\n\n this._chain = newChain;\n this._cache = resolveChain(newChain, this._environment);\n this._fetchCount += newChain.length;\n this._lastFetchAt = new Date().toISOString();\n\n this._diffAndFire(oldCache, this._cache, \"manual\");\n }\n\n /**\n * Close the runtime connection.\n *\n * Shuts down the WebSocket and cancels any pending reconnect timer.\n * Safe to call multiple times.\n */\n async close(): Promise<void> {\n this._closed = true;\n this._wsStatus = \"disconnected\";\n\n if (this._reconnectTimer !== null) {\n clearTimeout(this._reconnectTimer);\n this._reconnectTimer = null;\n }\n\n if (this._ws !== null) {\n this._ws.close();\n this._ws = null;\n }\n }\n\n /**\n * Async dispose support for `await using` (TypeScript 5.2+).\n */\n async [Symbol.asyncDispose](): Promise<void> {\n await this.close();\n }\n\n // ---- WebSocket internals ----\n\n private _buildWsUrl(): string {\n let url = this._baseUrl;\n if (url.startsWith(\"https://\")) {\n url = \"wss://\" + url.slice(\"https://\".length);\n } else if (url.startsWith(\"http://\")) {\n url = \"ws://\" + url.slice(\"http://\".length);\n } else {\n url = \"wss://\" + url;\n }\n url = url.replace(/\\/$/, \"\");\n return `${url}/api/ws/v1/configs?api_key=${this._apiKey}`;\n }\n\n private _connectWebSocket(): void {\n if (this._closed) return;\n\n this._wsStatus = \"connecting\";\n const wsUrl = this._buildWsUrl();\n\n try {\n const ws = new WebSocket(wsUrl);\n this._ws = ws;\n\n ws.on(\"open\", () => {\n if (this._closed) {\n ws.close();\n return;\n }\n this._backoffIndex = 0;\n this._wsStatus = \"connected\";\n ws.send(\n JSON.stringify({\n type: \"subscribe\",\n config_id: this._configId,\n environment: this._environment,\n }),\n );\n });\n\n ws.on(\"message\", (data: WebSocket.RawData) => {\n try {\n const msg = JSON.parse(String(data)) as WsMessage;\n this._handleMessage(msg);\n } catch {\n // ignore unparseable messages\n }\n });\n\n ws.on(\"close\", () => {\n if (!this._closed) {\n this._wsStatus = \"disconnected\";\n this._scheduleReconnect();\n }\n });\n\n ws.on(\"error\", () => {\n // 'close' will fire after 'error'; reconnect is handled there\n });\n } catch {\n if (!this._closed) {\n this._scheduleReconnect();\n }\n }\n }\n\n private _scheduleReconnect(): void {\n if (this._closed) return;\n\n const delay = BACKOFF_MS[Math.min(this._backoffIndex, BACKOFF_MS.length - 1)];\n this._backoffIndex++;\n this._wsStatus = \"connecting\";\n\n this._reconnectTimer = setTimeout(() => {\n this._reconnectTimer = null;\n // On reconnect, resync the cache to pick up changes missed while offline\n if (this._fetchChain) {\n this._fetchChain()\n .then((newChain) => {\n const oldCache = this._cache;\n this._chain = newChain;\n this._cache = resolveChain(newChain, this._environment);\n this._fetchCount += newChain.length;\n this._lastFetchAt = new Date().toISOString();\n this._diffAndFire(oldCache, this._cache, \"manual\");\n })\n .catch(() => {\n // ignore fetch errors during reconnect\n })\n .finally(() => {\n this._connectWebSocket();\n });\n } else {\n this._connectWebSocket();\n }\n }, delay);\n }\n\n private _handleMessage(msg: WsMessage): void {\n if (msg.type === \"config_changed\") {\n this._applyChanges(msg.config_id, msg.changes);\n } else if (msg.type === \"config_deleted\") {\n this._closed = true;\n void this.close();\n }\n }\n\n private _applyChanges(\n configId: string,\n changes: Array<{ key: string; old_value: unknown; new_value: unknown }>,\n ): void {\n const chainEntry = this._chain.find((c) => c.id === configId);\n if (!chainEntry) return;\n\n for (const change of changes) {\n const { key, new_value } = change;\n\n // Get or create the environment entry\n const envEntry =\n chainEntry.environments[this._environment] !== undefined &&\n chainEntry.environments[this._environment] !== null\n ? (chainEntry.environments[this._environment] as Record<string, unknown>)\n : null;\n const envValues =\n envEntry !== null && typeof envEntry === \"object\"\n ? ((envEntry.values ?? {}) as Record<string, unknown>)\n : null;\n\n if (new_value === null || new_value === undefined) {\n // Deletion: remove from base values and env values\n delete chainEntry.values[key];\n if (envValues) delete envValues[key];\n } else if (envValues && key in envValues) {\n // Update existing env-specific override\n envValues[key] = new_value;\n } else if (key in chainEntry.values) {\n // Update existing base value\n chainEntry.values[key] = new_value;\n } else {\n // New key — put in base values\n chainEntry.values[key] = new_value;\n }\n }\n\n const oldCache = this._cache;\n this._cache = resolveChain(this._chain, this._environment);\n this._diffAndFire(oldCache, this._cache, \"websocket\");\n }\n\n private _diffAndFire(\n oldCache: Record<string, unknown>,\n newCache: Record<string, unknown>,\n source: \"websocket\" | \"poll\" | \"manual\",\n ): void {\n const allKeys = new Set([...Object.keys(oldCache), ...Object.keys(newCache)]);\n\n for (const key of allKeys) {\n const oldVal = key in oldCache ? oldCache[key] : null;\n const newVal = key in newCache ? newCache[key] : null;\n\n if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {\n const event: ConfigChangeEvent = { key, oldValue: oldVal, newValue: newVal, source };\n this._fireListeners(event);\n }\n }\n }\n\n private _fireListeners(event: ConfigChangeEvent): void {\n for (const listener of this._listeners) {\n if (listener.key === null || listener.key === event.key) {\n try {\n listener.callback(event);\n } catch {\n // ignore listener errors to prevent one bad listener from stopping others\n }\n }\n }\n }\n}\n","/**\n * Deep-merge resolution algorithm for config inheritance chains.\n *\n * Mirrors the Python SDK's `_resolver.py` (ADR-024 §2.5–2.6).\n */\n\n/** A single entry in a config inheritance chain (child-to-root ordering). */\nexport interface ChainConfig {\n /** Config UUID. */\n id: string;\n /** Base key-value pairs. */\n values: Record<string, unknown>;\n /**\n * Per-environment overrides.\n * Each entry is `{ values: { key: value, ... } }` — the server wraps\n * environment-specific values in a nested `values` key.\n */\n environments: Record<string, unknown>;\n}\n\n/**\n * Recursively merge two dicts, with `override` taking precedence.\n *\n * Nested dicts are merged recursively. Non-dict values (strings, numbers,\n * booleans, arrays, null) are replaced wholesale.\n */\nexport function deepMerge(\n base: Record<string, unknown>,\n override: Record<string, unknown>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(override)) {\n if (\n key in result &&\n typeof result[key] === \"object\" &&\n result[key] !== null &&\n !Array.isArray(result[key]) &&\n typeof value === \"object\" &&\n value !== null &&\n !Array.isArray(value)\n ) {\n result[key] = deepMerge(\n result[key] as Record<string, unknown>,\n value as Record<string, unknown>,\n );\n } else {\n result[key] = value;\n }\n }\n return result;\n}\n\n/**\n * Resolve the full configuration for an environment given a config chain.\n *\n * Walks from root (last element) to child (first element), accumulating\n * values via deep merge so that child configs override parent configs.\n *\n * For each config in the chain, base `values` are merged with\n * environment-specific values (env wins), then that result is merged\n * on top of the accumulated parent result (child wins over parent).\n *\n * @param chain - Ordered list of config data from child (index 0) to root ancestor (last).\n * @param environment - The environment key to resolve for.\n */\nexport function resolveChain(chain: ChainConfig[], environment: string): Record<string, unknown> {\n let accumulated: Record<string, unknown> = {};\n\n // Walk from root to child (reverse order — chain is child-to-root)\n for (let i = chain.length - 1; i >= 0; i--) {\n const config = chain[i];\n const baseValues: Record<string, unknown> = config.values ?? {};\n\n // Environments are stored as { env_name: { values: { key: val } } }\n const envEntry = (config.environments ?? {})[environment];\n const envValues: Record<string, unknown> =\n envEntry !== null &&\n envEntry !== undefined &&\n typeof envEntry === \"object\" &&\n !Array.isArray(envEntry)\n ? (((envEntry as Record<string, unknown>).values ?? {}) as Record<string, unknown>)\n : {};\n\n // Merge environment overrides on top of base values (env wins)\n const configResolved = deepMerge(baseValues, envValues);\n\n // Merge this config's resolved values on top of accumulated parent values (child wins)\n accumulated = deepMerge(accumulated, configResolved);\n }\n\n return accumulated;\n}\n"],"mappings":"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","names":[]}
|