@smplkit/sdk 1.1.10 → 1.2.1
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 +1678 -409
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +658 -201
- package/dist/index.d.ts +658 -201
- package/dist/index.js +1664 -79
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/dist/chunk-RF6LYU4V.js +0 -317
- package/dist/chunk-RF6LYU4V.js.map +0 -1
- package/dist/runtime-FT745HBO.js +0 -7
- package/dist/runtime-FT745HBO.js.map +0 -1
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,346 +27,32 @@ 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 import_ws, BACKOFF_MS, ConfigRuntime;
|
|
72
|
-
var init_runtime = __esm({
|
|
73
|
-
"src/config/runtime.ts"() {
|
|
74
|
-
"use strict";
|
|
75
|
-
import_ws = __toESM(require("ws"), 1);
|
|
76
|
-
init_resolve();
|
|
77
|
-
BACKOFF_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 32e3, 6e4];
|
|
78
|
-
ConfigRuntime = class {
|
|
79
|
-
_cache;
|
|
80
|
-
_chain;
|
|
81
|
-
_fetchCount;
|
|
82
|
-
_lastFetchAt;
|
|
83
|
-
_closed = false;
|
|
84
|
-
_wsStatus = "disconnected";
|
|
85
|
-
_ws = null;
|
|
86
|
-
_reconnectTimer = null;
|
|
87
|
-
_backoffIndex = 0;
|
|
88
|
-
_listeners = [];
|
|
89
|
-
_configId;
|
|
90
|
-
_environment;
|
|
91
|
-
_apiKey;
|
|
92
|
-
_baseUrl;
|
|
93
|
-
_fetchChain;
|
|
94
|
-
/** @internal */
|
|
95
|
-
constructor(options) {
|
|
96
|
-
this._configId = options.configId;
|
|
97
|
-
this._environment = options.environment;
|
|
98
|
-
this._apiKey = options.apiKey;
|
|
99
|
-
this._baseUrl = options.baseUrl;
|
|
100
|
-
this._fetchChain = options.fetchChain;
|
|
101
|
-
this._chain = options.chain;
|
|
102
|
-
this._cache = resolveChain(options.chain, options.environment);
|
|
103
|
-
this._fetchCount = options.chain.length;
|
|
104
|
-
this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
105
|
-
this._connectWebSocket();
|
|
106
|
-
}
|
|
107
|
-
// ---- Value access (synchronous, local cache) ----
|
|
108
|
-
/**
|
|
109
|
-
* Return the resolved value for `key`, or `defaultValue` if absent.
|
|
110
|
-
*
|
|
111
|
-
* @param key - The config key to look up.
|
|
112
|
-
* @param defaultValue - Returned when the key is not present (default: null).
|
|
113
|
-
*/
|
|
114
|
-
get(key, defaultValue = null) {
|
|
115
|
-
return key in this._cache ? this._cache[key] : defaultValue;
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Return the value as a string, or `defaultValue` if absent or not a string.
|
|
119
|
-
*/
|
|
120
|
-
getString(key, defaultValue = null) {
|
|
121
|
-
const value = this._cache[key];
|
|
122
|
-
return typeof value === "string" ? value : defaultValue;
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Return the value as a number, or `defaultValue` if absent or not a number.
|
|
126
|
-
*/
|
|
127
|
-
getInt(key, defaultValue = null) {
|
|
128
|
-
const value = this._cache[key];
|
|
129
|
-
return typeof value === "number" ? value : defaultValue;
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Return the value as a boolean, or `defaultValue` if absent or not a boolean.
|
|
133
|
-
*/
|
|
134
|
-
getBool(key, defaultValue = null) {
|
|
135
|
-
const value = this._cache[key];
|
|
136
|
-
return typeof value === "boolean" ? value : defaultValue;
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Return whether `key` is present in the resolved configuration.
|
|
140
|
-
*/
|
|
141
|
-
exists(key) {
|
|
142
|
-
return key in this._cache;
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
145
|
-
* Return a shallow copy of the full resolved configuration.
|
|
146
|
-
*/
|
|
147
|
-
getAll() {
|
|
148
|
-
return { ...this._cache };
|
|
149
|
-
}
|
|
150
|
-
// ---- Change listeners ----
|
|
151
|
-
/**
|
|
152
|
-
* Register a listener that fires when a config value changes.
|
|
153
|
-
*
|
|
154
|
-
* @param callback - Called with a {@link ConfigChangeEvent} on each change.
|
|
155
|
-
* @param options.key - If provided, the listener fires only for this key.
|
|
156
|
-
* If omitted, the listener fires for all changes.
|
|
157
|
-
*/
|
|
158
|
-
onChange(callback, options) {
|
|
159
|
-
this._listeners.push({
|
|
160
|
-
callback,
|
|
161
|
-
key: options?.key ?? null
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
// ---- Diagnostics ----
|
|
165
|
-
/**
|
|
166
|
-
* Return diagnostic statistics for this runtime.
|
|
167
|
-
*/
|
|
168
|
-
stats() {
|
|
169
|
-
return {
|
|
170
|
-
fetchCount: this._fetchCount,
|
|
171
|
-
lastFetchAt: this._lastFetchAt
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Return the current WebSocket connection status.
|
|
176
|
-
*/
|
|
177
|
-
connectionStatus() {
|
|
178
|
-
return this._wsStatus;
|
|
179
|
-
}
|
|
180
|
-
// ---- Lifecycle ----
|
|
181
|
-
/**
|
|
182
|
-
* Force a manual refresh of the cached configuration.
|
|
183
|
-
*
|
|
184
|
-
* Re-fetches the full config chain via HTTP, re-resolves values, updates
|
|
185
|
-
* the local cache, and fires listeners for any detected changes.
|
|
186
|
-
*
|
|
187
|
-
* @throws {Error} If no `fetchChain` function was provided on construction.
|
|
188
|
-
*/
|
|
189
|
-
async refresh() {
|
|
190
|
-
if (!this._fetchChain) {
|
|
191
|
-
throw new Error("No fetchChain function provided; cannot refresh.");
|
|
192
|
-
}
|
|
193
|
-
const newChain = await this._fetchChain();
|
|
194
|
-
const oldCache = this._cache;
|
|
195
|
-
this._chain = newChain;
|
|
196
|
-
this._cache = resolveChain(newChain, this._environment);
|
|
197
|
-
this._fetchCount += newChain.length;
|
|
198
|
-
this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
199
|
-
this._diffAndFire(oldCache, this._cache, "manual");
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Close the runtime connection.
|
|
203
|
-
*
|
|
204
|
-
* Shuts down the WebSocket and cancels any pending reconnect timer.
|
|
205
|
-
* Safe to call multiple times.
|
|
206
|
-
*/
|
|
207
|
-
async close() {
|
|
208
|
-
this._closed = true;
|
|
209
|
-
this._wsStatus = "disconnected";
|
|
210
|
-
if (this._reconnectTimer !== null) {
|
|
211
|
-
clearTimeout(this._reconnectTimer);
|
|
212
|
-
this._reconnectTimer = null;
|
|
213
|
-
}
|
|
214
|
-
if (this._ws !== null) {
|
|
215
|
-
this._ws.close();
|
|
216
|
-
this._ws = null;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* Async dispose support for `await using` (TypeScript 5.2+).
|
|
221
|
-
*/
|
|
222
|
-
async [Symbol.asyncDispose]() {
|
|
223
|
-
await this.close();
|
|
224
|
-
}
|
|
225
|
-
// ---- WebSocket internals ----
|
|
226
|
-
_buildWsUrl() {
|
|
227
|
-
let url = this._baseUrl;
|
|
228
|
-
if (url.startsWith("https://")) {
|
|
229
|
-
url = "wss://" + url.slice("https://".length);
|
|
230
|
-
} else if (url.startsWith("http://")) {
|
|
231
|
-
url = "ws://" + url.slice("http://".length);
|
|
232
|
-
} else {
|
|
233
|
-
url = "wss://" + url;
|
|
234
|
-
}
|
|
235
|
-
url = url.replace(/\/$/, "");
|
|
236
|
-
return `${url}/api/ws/v1/configs?api_key=${this._apiKey}`;
|
|
237
|
-
}
|
|
238
|
-
_connectWebSocket() {
|
|
239
|
-
if (this._closed) return;
|
|
240
|
-
this._wsStatus = "connecting";
|
|
241
|
-
const wsUrl = this._buildWsUrl();
|
|
242
|
-
try {
|
|
243
|
-
const ws = new import_ws.default(wsUrl);
|
|
244
|
-
this._ws = ws;
|
|
245
|
-
ws.on("open", () => {
|
|
246
|
-
if (this._closed) {
|
|
247
|
-
ws.close();
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
this._backoffIndex = 0;
|
|
251
|
-
this._wsStatus = "connected";
|
|
252
|
-
ws.send(
|
|
253
|
-
JSON.stringify({
|
|
254
|
-
type: "subscribe",
|
|
255
|
-
config_id: this._configId,
|
|
256
|
-
environment: this._environment
|
|
257
|
-
})
|
|
258
|
-
);
|
|
259
|
-
});
|
|
260
|
-
ws.on("message", (data) => {
|
|
261
|
-
try {
|
|
262
|
-
const msg = JSON.parse(String(data));
|
|
263
|
-
this._handleMessage(msg);
|
|
264
|
-
} catch {
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
ws.on("close", () => {
|
|
268
|
-
if (!this._closed) {
|
|
269
|
-
this._wsStatus = "disconnected";
|
|
270
|
-
this._scheduleReconnect();
|
|
271
|
-
}
|
|
272
|
-
});
|
|
273
|
-
ws.on("error", () => {
|
|
274
|
-
});
|
|
275
|
-
} catch {
|
|
276
|
-
if (!this._closed) {
|
|
277
|
-
this._scheduleReconnect();
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
_scheduleReconnect() {
|
|
282
|
-
if (this._closed) return;
|
|
283
|
-
const delay = BACKOFF_MS[Math.min(this._backoffIndex, BACKOFF_MS.length - 1)];
|
|
284
|
-
this._backoffIndex++;
|
|
285
|
-
this._wsStatus = "connecting";
|
|
286
|
-
this._reconnectTimer = setTimeout(() => {
|
|
287
|
-
this._reconnectTimer = null;
|
|
288
|
-
if (this._fetchChain) {
|
|
289
|
-
this._fetchChain().then((newChain) => {
|
|
290
|
-
const oldCache = this._cache;
|
|
291
|
-
this._chain = newChain;
|
|
292
|
-
this._cache = resolveChain(newChain, this._environment);
|
|
293
|
-
this._fetchCount += newChain.length;
|
|
294
|
-
this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
295
|
-
this._diffAndFire(oldCache, this._cache, "manual");
|
|
296
|
-
}).catch(() => {
|
|
297
|
-
}).finally(() => {
|
|
298
|
-
this._connectWebSocket();
|
|
299
|
-
});
|
|
300
|
-
} else {
|
|
301
|
-
this._connectWebSocket();
|
|
302
|
-
}
|
|
303
|
-
}, delay);
|
|
304
|
-
}
|
|
305
|
-
_handleMessage(msg) {
|
|
306
|
-
if (msg.type === "config_changed") {
|
|
307
|
-
this._applyChanges(msg.config_id, msg.changes);
|
|
308
|
-
} else if (msg.type === "config_deleted") {
|
|
309
|
-
this._closed = true;
|
|
310
|
-
void this.close();
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
_applyChanges(configId, changes) {
|
|
314
|
-
const chainEntry = this._chain.find((c) => c.id === configId);
|
|
315
|
-
if (!chainEntry) return;
|
|
316
|
-
for (const change of changes) {
|
|
317
|
-
const { key, new_value } = change;
|
|
318
|
-
const envEntry = chainEntry.environments[this._environment] !== void 0 && chainEntry.environments[this._environment] !== null ? chainEntry.environments[this._environment] : null;
|
|
319
|
-
const envValues = envEntry !== null && typeof envEntry === "object" ? envEntry.values ?? {} : null;
|
|
320
|
-
if (new_value === null || new_value === void 0) {
|
|
321
|
-
delete chainEntry.items[key];
|
|
322
|
-
if (envValues) delete envValues[key];
|
|
323
|
-
} else if (envValues && key in envValues) {
|
|
324
|
-
envValues[key] = new_value;
|
|
325
|
-
} else if (key in chainEntry.items) {
|
|
326
|
-
chainEntry.items[key] = new_value;
|
|
327
|
-
} else {
|
|
328
|
-
chainEntry.items[key] = new_value;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
const oldCache = this._cache;
|
|
332
|
-
this._cache = resolveChain(this._chain, this._environment);
|
|
333
|
-
this._diffAndFire(oldCache, this._cache, "websocket");
|
|
334
|
-
}
|
|
335
|
-
_diffAndFire(oldCache, newCache, source) {
|
|
336
|
-
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldCache), ...Object.keys(newCache)]);
|
|
337
|
-
for (const key of allKeys) {
|
|
338
|
-
const oldVal = key in oldCache ? oldCache[key] : null;
|
|
339
|
-
const newVal = key in newCache ? newCache[key] : null;
|
|
340
|
-
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
341
|
-
const event = { key, oldValue: oldVal, newValue: newVal, source };
|
|
342
|
-
this._fireListeners(event);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
_fireListeners(event) {
|
|
347
|
-
for (const listener of this._listeners) {
|
|
348
|
-
if (listener.key === null || listener.key === event.key) {
|
|
349
|
-
try {
|
|
350
|
-
listener.callback(event);
|
|
351
|
-
} catch {
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
|
|
360
30
|
// src/index.ts
|
|
361
31
|
var index_exports = {};
|
|
362
32
|
__export(index_exports, {
|
|
33
|
+
BoolFlagHandle: () => BoolFlagHandle,
|
|
363
34
|
Config: () => Config,
|
|
364
35
|
ConfigClient: () => ConfigClient,
|
|
365
36
|
ConfigRuntime: () => ConfigRuntime,
|
|
37
|
+
Context: () => Context,
|
|
38
|
+
ContextType: () => ContextType,
|
|
39
|
+
Flag: () => Flag,
|
|
40
|
+
FlagChangeEvent: () => FlagChangeEvent,
|
|
41
|
+
FlagStats: () => FlagStats,
|
|
42
|
+
FlagsClient: () => FlagsClient,
|
|
43
|
+
JsonFlagHandle: () => JsonFlagHandle,
|
|
44
|
+
NumberFlagHandle: () => NumberFlagHandle,
|
|
45
|
+
Rule: () => Rule,
|
|
46
|
+
SharedWebSocket: () => SharedWebSocket,
|
|
366
47
|
SmplClient: () => SmplClient,
|
|
367
48
|
SmplConflictError: () => SmplConflictError,
|
|
368
49
|
SmplConnectionError: () => SmplConnectionError,
|
|
369
50
|
SmplError: () => SmplError,
|
|
51
|
+
SmplNotConnectedError: () => SmplNotConnectedError,
|
|
370
52
|
SmplNotFoundError: () => SmplNotFoundError,
|
|
371
53
|
SmplTimeoutError: () => SmplTimeoutError,
|
|
372
|
-
SmplValidationError: () => SmplValidationError
|
|
54
|
+
SmplValidationError: () => SmplValidationError,
|
|
55
|
+
StringFlagHandle: () => StringFlagHandle
|
|
373
56
|
});
|
|
374
57
|
module.exports = __toCommonJS(index_exports);
|
|
375
58
|
|
|
@@ -418,6 +101,13 @@ var SmplConflictError = class extends SmplError {
|
|
|
418
101
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
419
102
|
}
|
|
420
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
|
+
};
|
|
421
111
|
var SmplValidationError = class extends SmplError {
|
|
422
112
|
constructor(message, statusCode, responseBody) {
|
|
423
113
|
super(message, statusCode ?? 422, responseBody);
|
|
@@ -426,6 +116,34 @@ var SmplValidationError = class extends SmplError {
|
|
|
426
116
|
}
|
|
427
117
|
};
|
|
428
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
|
+
|
|
429
147
|
// src/config/types.ts
|
|
430
148
|
var Config = class {
|
|
431
149
|
/** UUID of the config. */
|
|
@@ -552,45 +270,6 @@ var Config = class {
|
|
|
552
270
|
await this.setValues(existing, environment);
|
|
553
271
|
}
|
|
554
272
|
}
|
|
555
|
-
/**
|
|
556
|
-
* Connect to this config for runtime value resolution.
|
|
557
|
-
*
|
|
558
|
-
* Eagerly fetches this config and its full parent chain, resolves values
|
|
559
|
-
* for the given environment via deep merge, and returns a
|
|
560
|
-
* {@link ConfigRuntime} with a fully populated local cache.
|
|
561
|
-
*
|
|
562
|
-
* A background WebSocket connection is started for real-time updates.
|
|
563
|
-
* If the WebSocket fails to connect, the runtime operates in cache-only
|
|
564
|
-
* mode and reconnects automatically.
|
|
565
|
-
*
|
|
566
|
-
* Supports both `await` and `await using` (TypeScript 5.2+)::
|
|
567
|
-
*
|
|
568
|
-
* ```typescript
|
|
569
|
-
* // Simple await
|
|
570
|
-
* const runtime = await config.connect("production");
|
|
571
|
-
* try { ... } finally { await runtime.close(); }
|
|
572
|
-
*
|
|
573
|
-
* // await using (auto-close)
|
|
574
|
-
* await using runtime = await config.connect("production");
|
|
575
|
-
* ```
|
|
576
|
-
*
|
|
577
|
-
* @param environment - The environment to resolve for (e.g. `"production"`).
|
|
578
|
-
* @param options.timeout - Milliseconds to wait for the initial fetch.
|
|
579
|
-
*/
|
|
580
|
-
async connect(environment, options) {
|
|
581
|
-
const { ConfigRuntime: ConfigRuntime2 } = await Promise.resolve().then(() => (init_runtime(), runtime_exports));
|
|
582
|
-
const timeout = options?.timeout ?? 3e4;
|
|
583
|
-
const chain = await this._buildChain(timeout);
|
|
584
|
-
return new ConfigRuntime2({
|
|
585
|
-
configKey: this.key,
|
|
586
|
-
configId: this.id,
|
|
587
|
-
environment,
|
|
588
|
-
chain,
|
|
589
|
-
apiKey: this._client._apiKey,
|
|
590
|
-
baseUrl: this._client._baseUrl,
|
|
591
|
-
fetchChain: () => this._buildChain(timeout)
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
273
|
/**
|
|
595
274
|
* Walk the parent chain and return config data objects, child-to-root.
|
|
596
275
|
* @internal
|
|
@@ -735,6 +414,12 @@ var ConfigClient = class {
|
|
|
735
414
|
_baseUrl = BASE_URL;
|
|
736
415
|
/** @internal */
|
|
737
416
|
_http;
|
|
417
|
+
/** @internal — returns the shared WebSocket for real-time updates. */
|
|
418
|
+
_getSharedWs;
|
|
419
|
+
/** @internal — set by SmplClient after construction. */
|
|
420
|
+
_parent = null;
|
|
421
|
+
_configCache = {};
|
|
422
|
+
_connected = false;
|
|
738
423
|
/** @internal */
|
|
739
424
|
constructor(apiKey, timeout) {
|
|
740
425
|
this._apiKey = apiKey;
|
|
@@ -832,6 +517,44 @@ var ConfigClient = class {
|
|
|
832
517
|
wrapFetchError(err);
|
|
833
518
|
}
|
|
834
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
|
+
}
|
|
835
558
|
/**
|
|
836
559
|
* Internal: PUT a full config update and return the updated model.
|
|
837
560
|
*
|
|
@@ -899,62 +622,1608 @@ var ConfigClient = class {
|
|
|
899
622
|
}
|
|
900
623
|
};
|
|
901
624
|
|
|
902
|
-
// src/
|
|
903
|
-
var
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
625
|
+
// src/flags/client.ts
|
|
626
|
+
var import_openapi_fetch2 = __toESM(require("openapi-fetch"), 1);
|
|
627
|
+
|
|
628
|
+
// src/auth.ts
|
|
629
|
+
function buildAuthHeader(apiKey) {
|
|
630
|
+
return `Bearer ${apiKey}`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/transport.ts
|
|
634
|
+
var SDK_VERSION = "0.0.0";
|
|
635
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
636
|
+
var Transport = class {
|
|
637
|
+
apiKey;
|
|
638
|
+
timeout;
|
|
639
|
+
constructor(options) {
|
|
640
|
+
this.apiKey = options.apiKey;
|
|
641
|
+
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Send a GET request.
|
|
645
|
+
*
|
|
646
|
+
* @param url - Fully-qualified URL (e.g. `https://config.smplkit.com/api/v1/configs`).
|
|
647
|
+
* @param params - Optional query parameters.
|
|
648
|
+
* @returns Parsed JSON response body.
|
|
649
|
+
*/
|
|
650
|
+
async get(url, params) {
|
|
651
|
+
return this.request("GET", url, void 0, params);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Send a POST request with a JSON body.
|
|
655
|
+
*
|
|
656
|
+
* @param url - Fully-qualified URL.
|
|
657
|
+
* @param body - JSON-serializable request body.
|
|
658
|
+
* @returns Parsed JSON response body.
|
|
659
|
+
*/
|
|
660
|
+
async post(url, body) {
|
|
661
|
+
return this.request("POST", url, body);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Send a PUT request with a JSON body.
|
|
665
|
+
*
|
|
666
|
+
* @param url - Fully-qualified URL.
|
|
667
|
+
* @param body - JSON-serializable request body.
|
|
668
|
+
* @returns Parsed JSON response body.
|
|
669
|
+
*/
|
|
670
|
+
async put(url, body) {
|
|
671
|
+
return this.request("PUT", url, body);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Send a DELETE request.
|
|
675
|
+
*
|
|
676
|
+
* @param url - Fully-qualified URL.
|
|
677
|
+
* @returns Parsed JSON response body (empty object for 204 responses).
|
|
678
|
+
*/
|
|
679
|
+
async delete(url) {
|
|
680
|
+
return this.request("DELETE", url);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Core request method. Handles headers, timeouts, and error mapping.
|
|
684
|
+
*/
|
|
685
|
+
async request(method, url, body, params) {
|
|
686
|
+
if (params) {
|
|
687
|
+
const searchParams = new URLSearchParams(params);
|
|
688
|
+
url += `?${searchParams.toString()}`;
|
|
689
|
+
}
|
|
690
|
+
const headers = {
|
|
691
|
+
Authorization: buildAuthHeader(this.apiKey),
|
|
692
|
+
"User-Agent": `smplkit-typescript-sdk/${SDK_VERSION}`,
|
|
693
|
+
Accept: "application/vnd.api+json"
|
|
694
|
+
};
|
|
695
|
+
if (body !== void 0) {
|
|
696
|
+
headers["Content-Type"] = "application/vnd.api+json";
|
|
697
|
+
}
|
|
698
|
+
const controller = new AbortController();
|
|
699
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
700
|
+
let response;
|
|
701
|
+
try {
|
|
702
|
+
response = await fetch(url, {
|
|
703
|
+
method,
|
|
704
|
+
headers,
|
|
705
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
706
|
+
signal: controller.signal
|
|
707
|
+
});
|
|
708
|
+
} catch (error) {
|
|
709
|
+
clearTimeout(timeoutId);
|
|
710
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
711
|
+
throw new SmplTimeoutError(`Request timed out after ${this.timeout}ms`);
|
|
921
712
|
}
|
|
922
|
-
if (
|
|
923
|
-
|
|
924
|
-
if (eqIndex !== -1) {
|
|
925
|
-
const value = trimmed.slice(eqIndex + 1).trim();
|
|
926
|
-
if (value) return value;
|
|
927
|
-
}
|
|
713
|
+
if (error instanceof TypeError) {
|
|
714
|
+
throw new SmplConnectionError(`Network error: ${error.message}`);
|
|
928
715
|
}
|
|
716
|
+
throw new SmplConnectionError(
|
|
717
|
+
`Request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
718
|
+
);
|
|
719
|
+
} finally {
|
|
720
|
+
clearTimeout(timeoutId);
|
|
721
|
+
}
|
|
722
|
+
if (response.status === 204) {
|
|
723
|
+
return {};
|
|
724
|
+
}
|
|
725
|
+
const responseText = await response.text();
|
|
726
|
+
if (!response.ok) {
|
|
727
|
+
this.throwForStatus(response.status, responseText);
|
|
728
|
+
}
|
|
729
|
+
try {
|
|
730
|
+
return JSON.parse(responseText);
|
|
731
|
+
} catch {
|
|
732
|
+
throw new SmplError(`Invalid JSON response: ${responseText}`, response.status, responseText);
|
|
929
733
|
}
|
|
930
|
-
} catch {
|
|
931
734
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
735
|
+
/**
|
|
736
|
+
* Map HTTP error status codes to typed SDK exceptions.
|
|
737
|
+
*
|
|
738
|
+
* @throws {SmplNotFoundError} On 404.
|
|
739
|
+
* @throws {SmplConflictError} On 409.
|
|
740
|
+
* @throws {SmplValidationError} On 422.
|
|
741
|
+
* @throws {SmplError} On any other non-2xx status.
|
|
742
|
+
*/
|
|
743
|
+
throwForStatus(status, body) {
|
|
744
|
+
switch (status) {
|
|
745
|
+
case 404:
|
|
746
|
+
throw new SmplNotFoundError(body, 404, body);
|
|
747
|
+
case 409:
|
|
748
|
+
throw new SmplConflictError(body, 409, body);
|
|
749
|
+
case 422:
|
|
750
|
+
throw new SmplValidationError(body, 422, body);
|
|
751
|
+
default:
|
|
752
|
+
throw new SmplError(`HTTP ${status}: ${body}`, status, body);
|
|
753
|
+
}
|
|
942
754
|
}
|
|
943
755
|
};
|
|
944
756
|
|
|
945
|
-
// src/
|
|
946
|
-
|
|
757
|
+
// src/flags/models.ts
|
|
758
|
+
var Flag = class {
|
|
759
|
+
/** UUID of the flag. */
|
|
760
|
+
id;
|
|
761
|
+
/** Unique key within the account. */
|
|
762
|
+
key;
|
|
763
|
+
/** Human-readable display name. */
|
|
764
|
+
name;
|
|
765
|
+
/** Value type: BOOLEAN, STRING, NUMERIC, or JSON. */
|
|
766
|
+
type;
|
|
767
|
+
/** Flag-level default value. */
|
|
768
|
+
default;
|
|
769
|
+
/** Closed set of possible values. */
|
|
770
|
+
values;
|
|
771
|
+
/** Optional description. */
|
|
772
|
+
description;
|
|
773
|
+
/** Per-environment configuration. */
|
|
774
|
+
environments;
|
|
775
|
+
/** When the flag was created. */
|
|
776
|
+
createdAt;
|
|
777
|
+
/** When the flag was last updated. */
|
|
778
|
+
updatedAt;
|
|
779
|
+
/** @internal */
|
|
780
|
+
_client;
|
|
781
|
+
/** @internal */
|
|
782
|
+
constructor(client, fields) {
|
|
783
|
+
this._client = client;
|
|
784
|
+
this.id = fields.id;
|
|
785
|
+
this.key = fields.key;
|
|
786
|
+
this.name = fields.name;
|
|
787
|
+
this.type = fields.type;
|
|
788
|
+
this.default = fields.default;
|
|
789
|
+
this.values = fields.values;
|
|
790
|
+
this.description = fields.description;
|
|
791
|
+
this.environments = fields.environments;
|
|
792
|
+
this.createdAt = fields.createdAt;
|
|
793
|
+
this.updatedAt = fields.updatedAt;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Update this flag's attributes on the server.
|
|
797
|
+
*
|
|
798
|
+
* Only provided fields are changed; others retain their current values.
|
|
799
|
+
*/
|
|
800
|
+
async update(options) {
|
|
801
|
+
const updated = await this._client._updateFlag({
|
|
802
|
+
flag: this,
|
|
803
|
+
environments: options.environments,
|
|
804
|
+
values: options.values,
|
|
805
|
+
default: options.default,
|
|
806
|
+
description: options.description,
|
|
807
|
+
name: options.name
|
|
808
|
+
});
|
|
809
|
+
this._apply(updated);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Add a rule to a specific environment.
|
|
813
|
+
*
|
|
814
|
+
* The built rule must include an `environment` key (set via
|
|
815
|
+
* `Rule(...).environment("env_key")`). Re-fetches current state
|
|
816
|
+
* first to avoid stale data.
|
|
817
|
+
*/
|
|
818
|
+
async addRule(builtRule) {
|
|
819
|
+
const envKey = builtRule.environment;
|
|
820
|
+
if (!envKey) {
|
|
821
|
+
throw new Error(
|
|
822
|
+
`Built rule must include 'environment' key. Use new Rule(...).environment("env_key").when(...).serve(...).build()`
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
const current = await this._client.get(this.id);
|
|
826
|
+
this._apply(current);
|
|
827
|
+
const envs = { ...this.environments };
|
|
828
|
+
const envData = { ...envs[envKey] ?? { enabled: true, rules: [] } };
|
|
829
|
+
const rules = [...envData.rules ?? []];
|
|
830
|
+
const { environment: _env, ...ruleCopy } = builtRule;
|
|
831
|
+
rules.push(ruleCopy);
|
|
832
|
+
envData.rules = rules;
|
|
833
|
+
envs[envKey] = envData;
|
|
834
|
+
const updated = await this._client._updateFlag({
|
|
835
|
+
flag: this,
|
|
836
|
+
environments: envs
|
|
837
|
+
});
|
|
838
|
+
this._apply(updated);
|
|
839
|
+
}
|
|
840
|
+
/** @internal */
|
|
841
|
+
_apply(other) {
|
|
842
|
+
this.id = other.id;
|
|
843
|
+
this.key = other.key;
|
|
844
|
+
this.name = other.name;
|
|
845
|
+
this.type = other.type;
|
|
846
|
+
this.default = other.default;
|
|
847
|
+
this.values = other.values;
|
|
848
|
+
this.description = other.description;
|
|
849
|
+
this.environments = other.environments;
|
|
850
|
+
this.createdAt = other.createdAt;
|
|
851
|
+
this.updatedAt = other.updatedAt;
|
|
852
|
+
}
|
|
853
|
+
toString() {
|
|
854
|
+
return `Flag(key=${this.key}, type=${this.type}, default=${this.default})`;
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
var ContextType = class {
|
|
858
|
+
/** UUID. */
|
|
859
|
+
id;
|
|
860
|
+
/** Unique key within the account. */
|
|
861
|
+
key;
|
|
862
|
+
/** Human-readable display name. */
|
|
863
|
+
name;
|
|
864
|
+
/** Known attributes. */
|
|
865
|
+
attributes;
|
|
866
|
+
constructor(fields) {
|
|
867
|
+
this.id = fields.id;
|
|
868
|
+
this.key = fields.key;
|
|
869
|
+
this.name = fields.name;
|
|
870
|
+
this.attributes = fields.attributes;
|
|
871
|
+
}
|
|
872
|
+
toString() {
|
|
873
|
+
return `ContextType(key=${this.key}, name=${this.name})`;
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// src/flags/client.ts
|
|
878
|
+
var import_json_logic_js = __toESM(require("json-logic-js"), 1);
|
|
879
|
+
var FLAGS_BASE_URL = "https://flags.smplkit.com";
|
|
880
|
+
var APP_BASE_URL = "https://app.smplkit.com";
|
|
881
|
+
var CACHE_MAX_SIZE = 1e4;
|
|
882
|
+
var CONTEXT_REGISTRATION_LRU_SIZE = 1e4;
|
|
883
|
+
var CONTEXT_BATCH_FLUSH_SIZE = 100;
|
|
884
|
+
async function checkError2(response, context) {
|
|
885
|
+
const body = await response.text().catch(() => "");
|
|
886
|
+
switch (response.status) {
|
|
887
|
+
case 404:
|
|
888
|
+
throw new SmplNotFoundError(body || context, 404, body);
|
|
889
|
+
case 409:
|
|
890
|
+
throw new SmplConflictError(body || context, 409, body);
|
|
891
|
+
case 422:
|
|
892
|
+
throw new SmplValidationError(body || context, 422, body);
|
|
893
|
+
default:
|
|
894
|
+
throw new SmplError(`HTTP ${response.status}: ${body}`, response.status, body);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
function wrapFetchError2(err) {
|
|
898
|
+
if (err instanceof SmplNotFoundError || err instanceof SmplConflictError || err instanceof SmplValidationError || err instanceof SmplError) {
|
|
899
|
+
throw err;
|
|
900
|
+
}
|
|
901
|
+
if (err instanceof TypeError) {
|
|
902
|
+
throw new SmplConnectionError(`Network error: ${err.message}`);
|
|
903
|
+
}
|
|
904
|
+
throw new SmplConnectionError(
|
|
905
|
+
`Request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
function contextsToEvalDict(contexts) {
|
|
909
|
+
const result = {};
|
|
910
|
+
for (const ctx of contexts) {
|
|
911
|
+
result[ctx.type] = { key: ctx.key, ...ctx.attributes };
|
|
912
|
+
}
|
|
913
|
+
return result;
|
|
914
|
+
}
|
|
915
|
+
function sortedStringify(obj) {
|
|
916
|
+
if (obj === null || obj === void 0) return "null";
|
|
917
|
+
if (typeof obj !== "object") return JSON.stringify(obj);
|
|
918
|
+
if (Array.isArray(obj)) return "[" + obj.map(sortedStringify).join(",") + "]";
|
|
919
|
+
const keys = Object.keys(obj).sort();
|
|
920
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + sortedStringify(obj[k])).join(",") + "}";
|
|
921
|
+
}
|
|
922
|
+
function hashContext(evalDict) {
|
|
923
|
+
const serialized = sortedStringify(evalDict);
|
|
924
|
+
let hash = 0;
|
|
925
|
+
for (let i = 0; i < serialized.length; i++) {
|
|
926
|
+
const chr = serialized.charCodeAt(i);
|
|
927
|
+
hash = (hash << 5) - hash + chr | 0;
|
|
928
|
+
}
|
|
929
|
+
return hash.toString(36);
|
|
930
|
+
}
|
|
931
|
+
function evaluateFlag(flagDef, environment, evalDict) {
|
|
932
|
+
const flagDefault = flagDef.default;
|
|
933
|
+
const environments = flagDef.environments ?? {};
|
|
934
|
+
if (environment === null || !(environment in environments)) {
|
|
935
|
+
return flagDefault;
|
|
936
|
+
}
|
|
937
|
+
const envConfig = environments[environment];
|
|
938
|
+
const envDefault = envConfig.default;
|
|
939
|
+
const fallback = envDefault !== void 0 && envDefault !== null ? envDefault : flagDefault;
|
|
940
|
+
if (!envConfig.enabled) {
|
|
941
|
+
return fallback;
|
|
942
|
+
}
|
|
943
|
+
const rules = envConfig.rules ?? [];
|
|
944
|
+
for (const rule of rules) {
|
|
945
|
+
const logic = rule.logic;
|
|
946
|
+
if (!logic || Object.keys(logic).length === 0) {
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
const result = import_json_logic_js.default.apply(logic, evalDict);
|
|
951
|
+
if (result) {
|
|
952
|
+
return rule.value;
|
|
953
|
+
}
|
|
954
|
+
} catch {
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return fallback;
|
|
959
|
+
}
|
|
960
|
+
var FlagChangeEvent = class {
|
|
961
|
+
key;
|
|
962
|
+
source;
|
|
963
|
+
constructor(key, source) {
|
|
964
|
+
this.key = key;
|
|
965
|
+
this.source = source;
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
var ResolutionCache = class {
|
|
969
|
+
_maxSize;
|
|
970
|
+
_cache = /* @__PURE__ */ new Map();
|
|
971
|
+
cacheHits = 0;
|
|
972
|
+
cacheMisses = 0;
|
|
973
|
+
constructor(maxSize = CACHE_MAX_SIZE) {
|
|
974
|
+
this._maxSize = maxSize;
|
|
975
|
+
}
|
|
976
|
+
get(cacheKey) {
|
|
977
|
+
if (this._cache.has(cacheKey)) {
|
|
978
|
+
const value = this._cache.get(cacheKey);
|
|
979
|
+
this._cache.delete(cacheKey);
|
|
980
|
+
this._cache.set(cacheKey, value);
|
|
981
|
+
this.cacheHits++;
|
|
982
|
+
return [true, value];
|
|
983
|
+
}
|
|
984
|
+
this.cacheMisses++;
|
|
985
|
+
return [false, null];
|
|
986
|
+
}
|
|
987
|
+
put(cacheKey, value) {
|
|
988
|
+
if (this._cache.has(cacheKey)) {
|
|
989
|
+
this._cache.delete(cacheKey);
|
|
990
|
+
}
|
|
991
|
+
this._cache.set(cacheKey, value);
|
|
992
|
+
if (this._cache.size > this._maxSize) {
|
|
993
|
+
const firstKey = this._cache.keys().next().value;
|
|
994
|
+
if (firstKey !== void 0) {
|
|
995
|
+
this._cache.delete(firstKey);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
clear() {
|
|
1000
|
+
this._cache.clear();
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
var FlagStats = class {
|
|
1004
|
+
cacheHits;
|
|
1005
|
+
cacheMisses;
|
|
1006
|
+
constructor(cacheHits, cacheMisses) {
|
|
1007
|
+
this.cacheHits = cacheHits;
|
|
1008
|
+
this.cacheMisses = cacheMisses;
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
var FlagHandleBase = class {
|
|
1012
|
+
/** @internal */
|
|
1013
|
+
_namespace;
|
|
1014
|
+
/** @internal */
|
|
1015
|
+
_key;
|
|
1016
|
+
/** @internal */
|
|
1017
|
+
_default;
|
|
1018
|
+
/** @internal */
|
|
1019
|
+
_listeners = [];
|
|
1020
|
+
constructor(namespace, key, defaultValue) {
|
|
1021
|
+
this._namespace = namespace;
|
|
1022
|
+
this._key = key;
|
|
1023
|
+
this._default = defaultValue;
|
|
1024
|
+
}
|
|
1025
|
+
get key() {
|
|
1026
|
+
return this._key;
|
|
1027
|
+
}
|
|
1028
|
+
get default() {
|
|
1029
|
+
return this._default;
|
|
1030
|
+
}
|
|
1031
|
+
/* v8 ignore next 3 — overridden by all exported subclasses */
|
|
1032
|
+
get(options) {
|
|
1033
|
+
return this._namespace._evaluateHandle(this._key, this._default, options?.context ?? null);
|
|
1034
|
+
}
|
|
1035
|
+
/** Register a flag-specific change listener. Works as a decorator. */
|
|
1036
|
+
onChange(callback) {
|
|
1037
|
+
this._listeners.push(callback);
|
|
1038
|
+
return callback;
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
var BoolFlagHandle = class extends FlagHandleBase {
|
|
1042
|
+
get(options) {
|
|
1043
|
+
const value = this._namespace._evaluateHandle(
|
|
1044
|
+
this._key,
|
|
1045
|
+
this._default,
|
|
1046
|
+
options?.context ?? null
|
|
1047
|
+
);
|
|
1048
|
+
if (typeof value === "boolean") {
|
|
1049
|
+
return value;
|
|
1050
|
+
}
|
|
1051
|
+
return this._default;
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
var StringFlagHandle = class extends FlagHandleBase {
|
|
1055
|
+
get(options) {
|
|
1056
|
+
const value = this._namespace._evaluateHandle(
|
|
1057
|
+
this._key,
|
|
1058
|
+
this._default,
|
|
1059
|
+
options?.context ?? null
|
|
1060
|
+
);
|
|
1061
|
+
if (typeof value === "string") {
|
|
1062
|
+
return value;
|
|
1063
|
+
}
|
|
1064
|
+
return this._default;
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
var NumberFlagHandle = class extends FlagHandleBase {
|
|
1068
|
+
get(options) {
|
|
1069
|
+
const value = this._namespace._evaluateHandle(
|
|
1070
|
+
this._key,
|
|
1071
|
+
this._default,
|
|
1072
|
+
options?.context ?? null
|
|
1073
|
+
);
|
|
1074
|
+
if (typeof value === "number") {
|
|
1075
|
+
return value;
|
|
1076
|
+
}
|
|
1077
|
+
return this._default;
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
var JsonFlagHandle = class extends FlagHandleBase {
|
|
1081
|
+
get(options) {
|
|
1082
|
+
const value = this._namespace._evaluateHandle(
|
|
1083
|
+
this._key,
|
|
1084
|
+
this._default,
|
|
1085
|
+
options?.context ?? null
|
|
1086
|
+
);
|
|
1087
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1088
|
+
return value;
|
|
1089
|
+
}
|
|
1090
|
+
return this._default;
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
var ContextRegistrationBuffer = class {
|
|
1094
|
+
_seen = /* @__PURE__ */ new Map();
|
|
1095
|
+
_pending = [];
|
|
1096
|
+
observe(contexts) {
|
|
1097
|
+
for (const ctx of contexts) {
|
|
1098
|
+
const cacheKey = `${ctx.type}:${ctx.key}`;
|
|
1099
|
+
if (!this._seen.has(cacheKey)) {
|
|
1100
|
+
if (this._seen.size >= CONTEXT_REGISTRATION_LRU_SIZE) {
|
|
1101
|
+
const firstKey = this._seen.keys().next().value;
|
|
1102
|
+
if (firstKey !== void 0) {
|
|
1103
|
+
this._seen.delete(firstKey);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
this._seen.set(cacheKey, ctx.attributes);
|
|
1107
|
+
this._pending.push({
|
|
1108
|
+
id: `${ctx.type}:${ctx.key}`,
|
|
1109
|
+
name: ctx.name ?? ctx.key,
|
|
1110
|
+
attributes: { ...ctx.attributes }
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
drain() {
|
|
1116
|
+
const batch = this._pending;
|
|
1117
|
+
this._pending = [];
|
|
1118
|
+
return batch;
|
|
1119
|
+
}
|
|
1120
|
+
get pendingCount() {
|
|
1121
|
+
return this._pending.length;
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
var FlagsClient = class {
|
|
1125
|
+
/** @internal */
|
|
1126
|
+
_apiKey;
|
|
1127
|
+
/** @internal */
|
|
1128
|
+
_baseUrl = FLAGS_BASE_URL;
|
|
1129
|
+
/** @internal */
|
|
1130
|
+
_http;
|
|
1131
|
+
/** @internal */
|
|
1132
|
+
_transport;
|
|
1133
|
+
// Runtime state
|
|
1134
|
+
_environment = null;
|
|
1135
|
+
_flagStore = {};
|
|
1136
|
+
_connected = false;
|
|
1137
|
+
_cache = new ResolutionCache();
|
|
1138
|
+
_contextProvider = null;
|
|
1139
|
+
_contextBuffer = new ContextRegistrationBuffer();
|
|
1140
|
+
_handles = {};
|
|
1141
|
+
_globalListeners = [];
|
|
1142
|
+
// Shared WebSocket (set during connect)
|
|
1143
|
+
_wsManager = null;
|
|
1144
|
+
_ensureWs;
|
|
1145
|
+
/** @internal — set by SmplClient after construction. */
|
|
1146
|
+
_parent = null;
|
|
1147
|
+
/** @internal */
|
|
1148
|
+
constructor(apiKey, ensureWs, timeout) {
|
|
1149
|
+
this._apiKey = apiKey;
|
|
1150
|
+
this._ensureWs = ensureWs;
|
|
1151
|
+
const ms = timeout ?? 3e4;
|
|
1152
|
+
this._http = (0, import_openapi_fetch2.default)({
|
|
1153
|
+
baseUrl: FLAGS_BASE_URL,
|
|
1154
|
+
headers: {
|
|
1155
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1156
|
+
Accept: "application/json"
|
|
1157
|
+
},
|
|
1158
|
+
fetch: async (request) => {
|
|
1159
|
+
const controller = new AbortController();
|
|
1160
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
1161
|
+
try {
|
|
1162
|
+
return await fetch(new Request(request, { signal: controller.signal }));
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
1165
|
+
throw new SmplTimeoutError(`Request timed out after ${ms}ms`);
|
|
1166
|
+
}
|
|
1167
|
+
throw err;
|
|
1168
|
+
} finally {
|
|
1169
|
+
clearTimeout(timer);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
this._transport = new Transport({ apiKey, timeout: ms });
|
|
1174
|
+
}
|
|
1175
|
+
// ------------------------------------------------------------------
|
|
1176
|
+
// Management methods
|
|
1177
|
+
// ------------------------------------------------------------------
|
|
1178
|
+
/** Create a flag. */
|
|
1179
|
+
async create(key, options) {
|
|
1180
|
+
let values = options.values;
|
|
1181
|
+
if (values === void 0 && options.type === "BOOLEAN") {
|
|
1182
|
+
values = [
|
|
1183
|
+
{ name: "True", value: true },
|
|
1184
|
+
{ name: "False", value: false }
|
|
1185
|
+
];
|
|
1186
|
+
}
|
|
1187
|
+
const body = {
|
|
1188
|
+
data: {
|
|
1189
|
+
type: "flag",
|
|
1190
|
+
attributes: {
|
|
1191
|
+
key,
|
|
1192
|
+
name: options.name,
|
|
1193
|
+
description: options.description ?? "",
|
|
1194
|
+
type: options.type,
|
|
1195
|
+
default: options.default,
|
|
1196
|
+
values: values ?? []
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
let data;
|
|
1201
|
+
try {
|
|
1202
|
+
const result = await this._http.POST("/api/v1/flags", { body });
|
|
1203
|
+
if (result.error !== void 0) await checkError2(result.response, "Failed to create flag");
|
|
1204
|
+
data = result.data;
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
wrapFetchError2(err);
|
|
1207
|
+
}
|
|
1208
|
+
if (!data || !data.data) throw new SmplValidationError("Failed to create flag");
|
|
1209
|
+
return this._resourceToModel(data.data);
|
|
1210
|
+
}
|
|
1211
|
+
/** Fetch a flag by UUID. */
|
|
1212
|
+
async get(flagId) {
|
|
1213
|
+
let data;
|
|
1214
|
+
try {
|
|
1215
|
+
const result = await this._http.GET("/api/v1/flags/{id}", {
|
|
1216
|
+
params: { path: { id: flagId } }
|
|
1217
|
+
});
|
|
1218
|
+
if (result.error !== void 0) await checkError2(result.response, `Flag ${flagId} not found`);
|
|
1219
|
+
data = result.data;
|
|
1220
|
+
} catch (err) {
|
|
1221
|
+
wrapFetchError2(err);
|
|
1222
|
+
}
|
|
1223
|
+
if (!data || !data.data) throw new SmplNotFoundError(`Flag ${flagId} not found`);
|
|
1224
|
+
return this._resourceToModel(data.data);
|
|
1225
|
+
}
|
|
1226
|
+
/** List all flags. */
|
|
1227
|
+
async list() {
|
|
1228
|
+
let data;
|
|
1229
|
+
try {
|
|
1230
|
+
const result = await this._http.GET("/api/v1/flags", {});
|
|
1231
|
+
if (result.error !== void 0) await checkError2(result.response, "Failed to list flags");
|
|
1232
|
+
data = result.data;
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
wrapFetchError2(err);
|
|
1235
|
+
}
|
|
1236
|
+
if (!data) return [];
|
|
1237
|
+
return data.data.map((r) => this._resourceToModel(r));
|
|
1238
|
+
}
|
|
1239
|
+
/** Delete a flag by UUID. */
|
|
1240
|
+
async delete(flagId) {
|
|
1241
|
+
try {
|
|
1242
|
+
const result = await this._http.DELETE("/api/v1/flags/{id}", {
|
|
1243
|
+
params: { path: { id: flagId } }
|
|
1244
|
+
});
|
|
1245
|
+
if (result.error !== void 0 && result.response.status !== 204)
|
|
1246
|
+
await checkError2(result.response, `Failed to delete flag ${flagId}`);
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
wrapFetchError2(err);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Internal: PUT a full flag update.
|
|
1253
|
+
* Called by {@link Flag} instance methods.
|
|
1254
|
+
* @internal
|
|
1255
|
+
*/
|
|
1256
|
+
async _updateFlag(options) {
|
|
1257
|
+
const { flag } = options;
|
|
1258
|
+
const body = {
|
|
1259
|
+
data: {
|
|
1260
|
+
type: "flag",
|
|
1261
|
+
attributes: {
|
|
1262
|
+
key: flag.key,
|
|
1263
|
+
name: options.name !== void 0 ? options.name : flag.name,
|
|
1264
|
+
type: flag.type,
|
|
1265
|
+
default: options.default !== void 0 ? options.default : flag.default,
|
|
1266
|
+
values: options.values !== void 0 ? options.values : flag.values,
|
|
1267
|
+
description: options.description !== void 0 ? options.description : flag.description ?? "",
|
|
1268
|
+
...options.environments !== void 0 ? { environments: options.environments } : flag.environments && Object.keys(flag.environments).length > 0 ? { environments: flag.environments } : {}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
let data;
|
|
1273
|
+
try {
|
|
1274
|
+
const result = await this._http.PUT("/api/v1/flags/{id}", {
|
|
1275
|
+
params: { path: { id: flag.id } },
|
|
1276
|
+
body
|
|
1277
|
+
});
|
|
1278
|
+
if (result.error !== void 0)
|
|
1279
|
+
await checkError2(result.response, `Failed to update flag ${flag.id}`);
|
|
1280
|
+
data = result.data;
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
wrapFetchError2(err);
|
|
1283
|
+
}
|
|
1284
|
+
if (!data || !data.data) throw new SmplValidationError(`Failed to update flag ${flag.id}`);
|
|
1285
|
+
return this._resourceToModel(data.data);
|
|
1286
|
+
}
|
|
1287
|
+
// ------------------------------------------------------------------
|
|
1288
|
+
// Context type management (direct HTTP — not in generated spec)
|
|
1289
|
+
// ------------------------------------------------------------------
|
|
1290
|
+
/** Create a context type. */
|
|
1291
|
+
async createContextType(key, options) {
|
|
1292
|
+
const resp = await this._transport.post(`${APP_BASE_URL}/api/v1/context_types`, {
|
|
1293
|
+
data: { type: "context_type", attributes: { key, name: options.name } }
|
|
1294
|
+
});
|
|
1295
|
+
const data = resp.data ?? {};
|
|
1296
|
+
return this._parseContextType(data);
|
|
1297
|
+
}
|
|
1298
|
+
/** Update a context type (merge attributes). */
|
|
1299
|
+
async updateContextType(ctId, options) {
|
|
1300
|
+
const resp = await this._transport.put(`${APP_BASE_URL}/api/v1/context_types/${ctId}`, {
|
|
1301
|
+
data: { type: "context_type", attributes: { attributes: options.attributes } }
|
|
1302
|
+
});
|
|
1303
|
+
const data = resp.data ?? {};
|
|
1304
|
+
return this._parseContextType(data);
|
|
1305
|
+
}
|
|
1306
|
+
/** List all context types. */
|
|
1307
|
+
async listContextTypes() {
|
|
1308
|
+
const resp = await this._transport.get(`${APP_BASE_URL}/api/v1/context_types`);
|
|
1309
|
+
const items = resp.data ?? [];
|
|
1310
|
+
return items.map((item) => this._parseContextType(item));
|
|
1311
|
+
}
|
|
1312
|
+
/** Delete a context type. */
|
|
1313
|
+
async deleteContextType(ctId) {
|
|
1314
|
+
await this._transport.delete(`${APP_BASE_URL}/api/v1/context_types/${ctId}`);
|
|
1315
|
+
}
|
|
1316
|
+
/** List context instances filtered by context type key. */
|
|
1317
|
+
async listContexts(options) {
|
|
1318
|
+
const resp = await this._transport.get(`${APP_BASE_URL}/api/v1/contexts`, {
|
|
1319
|
+
"filter[context_type]": options.contextTypeKey
|
|
1320
|
+
});
|
|
1321
|
+
return resp.data ?? [];
|
|
1322
|
+
}
|
|
1323
|
+
// ------------------------------------------------------------------
|
|
1324
|
+
// Runtime: typed flag handles
|
|
1325
|
+
// ------------------------------------------------------------------
|
|
1326
|
+
/** Declare a boolean flag handle. */
|
|
1327
|
+
boolFlag(key, defaultValue) {
|
|
1328
|
+
const handle = new BoolFlagHandle(this, key, defaultValue);
|
|
1329
|
+
this._handles[key] = handle;
|
|
1330
|
+
return handle;
|
|
1331
|
+
}
|
|
1332
|
+
/** Declare a string flag handle. */
|
|
1333
|
+
stringFlag(key, defaultValue) {
|
|
1334
|
+
const handle = new StringFlagHandle(this, key, defaultValue);
|
|
1335
|
+
this._handles[key] = handle;
|
|
1336
|
+
return handle;
|
|
1337
|
+
}
|
|
1338
|
+
/** Declare a numeric flag handle. */
|
|
1339
|
+
numberFlag(key, defaultValue) {
|
|
1340
|
+
const handle = new NumberFlagHandle(this, key, defaultValue);
|
|
1341
|
+
this._handles[key] = handle;
|
|
1342
|
+
return handle;
|
|
1343
|
+
}
|
|
1344
|
+
/** Declare a JSON flag handle. */
|
|
1345
|
+
jsonFlag(key, defaultValue) {
|
|
1346
|
+
const handle = new JsonFlagHandle(this, key, defaultValue);
|
|
1347
|
+
this._handles[key] = handle;
|
|
1348
|
+
return handle;
|
|
1349
|
+
}
|
|
1350
|
+
// ------------------------------------------------------------------
|
|
1351
|
+
// Runtime: context provider
|
|
1352
|
+
// ------------------------------------------------------------------
|
|
1353
|
+
/**
|
|
1354
|
+
* Register a context provider function.
|
|
1355
|
+
*
|
|
1356
|
+
* Called on every `handle.get()` to supply the current evaluation
|
|
1357
|
+
* context. Can also be used as a decorator:
|
|
1358
|
+
*
|
|
1359
|
+
* ```typescript
|
|
1360
|
+
* client.flags.setContextProvider(() => [
|
|
1361
|
+
* new Context("user", userId, { plan: userPlan }),
|
|
1362
|
+
* ]);
|
|
1363
|
+
* ```
|
|
1364
|
+
*/
|
|
1365
|
+
setContextProvider(fn) {
|
|
1366
|
+
this._contextProvider = fn;
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Register a context provider — decorator-style alias.
|
|
1370
|
+
*
|
|
1371
|
+
* ```typescript
|
|
1372
|
+
* const provider = client.flags.contextProvider(() => [...]);
|
|
1373
|
+
* ```
|
|
1374
|
+
*/
|
|
1375
|
+
contextProvider(fn) {
|
|
1376
|
+
this._contextProvider = fn;
|
|
1377
|
+
return fn;
|
|
1378
|
+
}
|
|
1379
|
+
// ------------------------------------------------------------------
|
|
1380
|
+
// Runtime: connect / disconnect / refresh
|
|
1381
|
+
// ------------------------------------------------------------------
|
|
1382
|
+
/**
|
|
1383
|
+
* Connect to an environment: fetch flag definitions, register on
|
|
1384
|
+
* shared WebSocket, enable local evaluation.
|
|
1385
|
+
* @internal — called by SmplClient.connect().
|
|
1386
|
+
*/
|
|
1387
|
+
async _connectInternal(environment) {
|
|
1388
|
+
this._environment = environment;
|
|
1389
|
+
await this._fetchAllFlags();
|
|
1390
|
+
this._connected = true;
|
|
1391
|
+
this._cache.clear();
|
|
1392
|
+
this._wsManager = this._ensureWs();
|
|
1393
|
+
this._wsManager.on("flag_changed", this._handleFlagChanged);
|
|
1394
|
+
this._wsManager.on("flag_deleted", this._handleFlagDeleted);
|
|
1395
|
+
}
|
|
1396
|
+
/** Disconnect: unregister from WebSocket, flush contexts, clear state. */
|
|
1397
|
+
async disconnect() {
|
|
1398
|
+
if (this._wsManager !== null) {
|
|
1399
|
+
this._wsManager.off("flag_changed", this._handleFlagChanged);
|
|
1400
|
+
this._wsManager.off("flag_deleted", this._handleFlagDeleted);
|
|
1401
|
+
this._wsManager = null;
|
|
1402
|
+
}
|
|
1403
|
+
await this._flushContexts();
|
|
1404
|
+
this._flagStore = {};
|
|
1405
|
+
this._cache.clear();
|
|
1406
|
+
this._connected = false;
|
|
1407
|
+
this._environment = null;
|
|
1408
|
+
}
|
|
1409
|
+
/** Re-fetch all flag definitions and clear cache. */
|
|
1410
|
+
async refresh() {
|
|
1411
|
+
await this._fetchAllFlags();
|
|
1412
|
+
this._cache.clear();
|
|
1413
|
+
this._fireChangeListenersAll("manual");
|
|
1414
|
+
}
|
|
1415
|
+
/** Return the current WebSocket connection status. */
|
|
1416
|
+
connectionStatus() {
|
|
1417
|
+
if (this._wsManager !== null) {
|
|
1418
|
+
return this._wsManager.connectionStatus;
|
|
1419
|
+
}
|
|
1420
|
+
return "disconnected";
|
|
1421
|
+
}
|
|
1422
|
+
/** Return cache statistics. */
|
|
1423
|
+
stats() {
|
|
1424
|
+
return new FlagStats(this._cache.cacheHits, this._cache.cacheMisses);
|
|
1425
|
+
}
|
|
1426
|
+
// ------------------------------------------------------------------
|
|
1427
|
+
// Runtime: change listeners
|
|
1428
|
+
// ------------------------------------------------------------------
|
|
1429
|
+
/** Register a global change listener that fires for any flag change. */
|
|
1430
|
+
onChangeAny(callback) {
|
|
1431
|
+
this._globalListeners.push(callback);
|
|
1432
|
+
return callback;
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Register a global change listener — decorator-style alias.
|
|
1436
|
+
*
|
|
1437
|
+
* ```typescript
|
|
1438
|
+
* const listener = client.flags.onChange((event) => { ... });
|
|
1439
|
+
* ```
|
|
1440
|
+
*/
|
|
1441
|
+
onChange(callback) {
|
|
1442
|
+
return this.onChangeAny(callback);
|
|
1443
|
+
}
|
|
1444
|
+
// ------------------------------------------------------------------
|
|
1445
|
+
// Runtime: context registration
|
|
1446
|
+
// ------------------------------------------------------------------
|
|
1447
|
+
/**
|
|
1448
|
+
* Explicitly register context(s) for background batch registration.
|
|
1449
|
+
*
|
|
1450
|
+
* Accepts a single Context or an array. Fire-and-forget — never
|
|
1451
|
+
* blocks. Works before `connect()` is called.
|
|
1452
|
+
*/
|
|
1453
|
+
register(context) {
|
|
1454
|
+
if (Array.isArray(context)) {
|
|
1455
|
+
this._contextBuffer.observe(context);
|
|
1456
|
+
} else {
|
|
1457
|
+
this._contextBuffer.observe([context]);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
/** Flush pending context registrations to the server. */
|
|
1461
|
+
async flushContexts() {
|
|
1462
|
+
await this._flushContexts();
|
|
1463
|
+
}
|
|
1464
|
+
// ------------------------------------------------------------------
|
|
1465
|
+
// Runtime: Tier 1 evaluate
|
|
1466
|
+
// ------------------------------------------------------------------
|
|
1467
|
+
/**
|
|
1468
|
+
* Tier 1 explicit evaluation — stateless, no provider or cache.
|
|
1469
|
+
*
|
|
1470
|
+
* Useful for scripts, one-off jobs, and infrastructure code.
|
|
1471
|
+
*/
|
|
1472
|
+
async evaluate(key, options) {
|
|
1473
|
+
const evalDict = contextsToEvalDict(options.context);
|
|
1474
|
+
if (this._parent?._service && !("service" in evalDict)) {
|
|
1475
|
+
evalDict["service"] = { key: this._parent._service };
|
|
1476
|
+
}
|
|
1477
|
+
let flagDef = null;
|
|
1478
|
+
if (this._connected && key in this._flagStore) {
|
|
1479
|
+
flagDef = this._flagStore[key];
|
|
1480
|
+
} else {
|
|
1481
|
+
const flags = await this._fetchFlagsList();
|
|
1482
|
+
for (const f of flags) {
|
|
1483
|
+
if (f.key === key) {
|
|
1484
|
+
flagDef = f;
|
|
1485
|
+
break;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (flagDef === null) {
|
|
1490
|
+
return null;
|
|
1491
|
+
}
|
|
1492
|
+
return evaluateFlag(flagDef, options.environment, evalDict);
|
|
1493
|
+
}
|
|
1494
|
+
// ------------------------------------------------------------------
|
|
1495
|
+
// Internal: evaluation
|
|
1496
|
+
// ------------------------------------------------------------------
|
|
1497
|
+
/** @internal */
|
|
1498
|
+
_evaluateHandle(key, defaultValue, context) {
|
|
1499
|
+
if (!this._connected) {
|
|
1500
|
+
throw new SmplNotConnectedError("SmplClient is not connected. Call client.connect() first.");
|
|
1501
|
+
}
|
|
1502
|
+
let evalDict;
|
|
1503
|
+
if (context !== null) {
|
|
1504
|
+
evalDict = contextsToEvalDict(context);
|
|
1505
|
+
} else if (this._contextProvider !== null) {
|
|
1506
|
+
const contexts = this._contextProvider();
|
|
1507
|
+
evalDict = contextsToEvalDict(contexts);
|
|
1508
|
+
this._contextBuffer.observe(contexts);
|
|
1509
|
+
if (this._contextBuffer.pendingCount >= CONTEXT_BATCH_FLUSH_SIZE) {
|
|
1510
|
+
void this._flushContexts();
|
|
1511
|
+
}
|
|
1512
|
+
} else {
|
|
1513
|
+
evalDict = {};
|
|
1514
|
+
}
|
|
1515
|
+
if (this._parent?._service && !("service" in evalDict)) {
|
|
1516
|
+
evalDict["service"] = { key: this._parent._service };
|
|
1517
|
+
}
|
|
1518
|
+
const ctxHash = hashContext(evalDict);
|
|
1519
|
+
const cacheKey = `${key}:${ctxHash}`;
|
|
1520
|
+
const [hit, cachedValue] = this._cache.get(cacheKey);
|
|
1521
|
+
if (hit) {
|
|
1522
|
+
return cachedValue;
|
|
1523
|
+
}
|
|
1524
|
+
const flagDef = this._flagStore[key];
|
|
1525
|
+
if (flagDef === void 0) {
|
|
1526
|
+
this._cache.put(cacheKey, defaultValue);
|
|
1527
|
+
return defaultValue;
|
|
1528
|
+
}
|
|
1529
|
+
let value = evaluateFlag(flagDef, this._environment, evalDict);
|
|
1530
|
+
if (value === null || value === void 0) {
|
|
1531
|
+
value = defaultValue;
|
|
1532
|
+
}
|
|
1533
|
+
this._cache.put(cacheKey, value);
|
|
1534
|
+
return value;
|
|
1535
|
+
}
|
|
1536
|
+
// ------------------------------------------------------------------
|
|
1537
|
+
// Internal: event handlers (called by SharedWebSocket)
|
|
1538
|
+
// ------------------------------------------------------------------
|
|
1539
|
+
_handleFlagChanged = (data) => {
|
|
1540
|
+
const flagKey = data.key;
|
|
1541
|
+
void this._fetchAllFlags().then(() => {
|
|
1542
|
+
this._cache.clear();
|
|
1543
|
+
this._fireChangeListeners(flagKey ?? null, "websocket");
|
|
1544
|
+
});
|
|
1545
|
+
};
|
|
1546
|
+
_handleFlagDeleted = (data) => {
|
|
1547
|
+
const flagKey = data.key;
|
|
1548
|
+
void this._fetchAllFlags().then(() => {
|
|
1549
|
+
this._cache.clear();
|
|
1550
|
+
this._fireChangeListeners(flagKey ?? null, "websocket");
|
|
1551
|
+
});
|
|
1552
|
+
};
|
|
1553
|
+
// ------------------------------------------------------------------
|
|
1554
|
+
// Internal: flag store
|
|
1555
|
+
// ------------------------------------------------------------------
|
|
1556
|
+
async _fetchAllFlags() {
|
|
1557
|
+
const flags = await this._fetchFlagsList();
|
|
1558
|
+
const store = {};
|
|
1559
|
+
for (const f of flags) {
|
|
1560
|
+
store[f.key] = f;
|
|
1561
|
+
}
|
|
1562
|
+
this._flagStore = store;
|
|
1563
|
+
}
|
|
1564
|
+
async _fetchFlagsList() {
|
|
1565
|
+
let data;
|
|
1566
|
+
try {
|
|
1567
|
+
const result = await this._http.GET("/api/v1/flags", {});
|
|
1568
|
+
if (result.error !== void 0) await checkError2(result.response, "Failed to list flags");
|
|
1569
|
+
data = result.data;
|
|
1570
|
+
} catch (err) {
|
|
1571
|
+
wrapFetchError2(err);
|
|
1572
|
+
}
|
|
1573
|
+
if (!data) return [];
|
|
1574
|
+
return data.data.map((r) => this._resourceToPlainDict(r));
|
|
1575
|
+
}
|
|
1576
|
+
// ------------------------------------------------------------------
|
|
1577
|
+
// Internal: change listeners
|
|
1578
|
+
// ------------------------------------------------------------------
|
|
1579
|
+
_fireChangeListeners(flagKey, source) {
|
|
1580
|
+
if (flagKey) {
|
|
1581
|
+
const event = new FlagChangeEvent(flagKey, source);
|
|
1582
|
+
for (const cb of this._globalListeners) {
|
|
1583
|
+
try {
|
|
1584
|
+
cb(event);
|
|
1585
|
+
} catch {
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const handle = this._handles[flagKey];
|
|
1589
|
+
if (handle) {
|
|
1590
|
+
for (const cb of handle._listeners) {
|
|
1591
|
+
try {
|
|
1592
|
+
cb(event);
|
|
1593
|
+
} catch {
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
_fireChangeListenersAll(source) {
|
|
1600
|
+
for (const flagKey of Object.keys(this._flagStore)) {
|
|
1601
|
+
this._fireChangeListeners(flagKey, source);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
// ------------------------------------------------------------------
|
|
1605
|
+
// Internal: context flush
|
|
1606
|
+
// ------------------------------------------------------------------
|
|
1607
|
+
async _flushContexts() {
|
|
1608
|
+
const batch = this._contextBuffer.drain();
|
|
1609
|
+
if (batch.length === 0) return;
|
|
1610
|
+
try {
|
|
1611
|
+
await this._transport.put(`${APP_BASE_URL}/api/v1/contexts/bulk`, {
|
|
1612
|
+
contexts: batch
|
|
1613
|
+
});
|
|
1614
|
+
} catch {
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
// ------------------------------------------------------------------
|
|
1618
|
+
// Internal: model conversion
|
|
1619
|
+
// ------------------------------------------------------------------
|
|
1620
|
+
_resourceToModel(resource) {
|
|
1621
|
+
const attrs = resource.attributes;
|
|
1622
|
+
return new Flag(this, {
|
|
1623
|
+
id: resource.id ?? "",
|
|
1624
|
+
key: attrs.key,
|
|
1625
|
+
name: attrs.name,
|
|
1626
|
+
type: attrs.type,
|
|
1627
|
+
default: attrs.default,
|
|
1628
|
+
values: (attrs.values ?? []).map((v) => ({ name: v.name, value: v.value })),
|
|
1629
|
+
description: attrs.description ?? null,
|
|
1630
|
+
environments: attrs.environments ?? {},
|
|
1631
|
+
createdAt: attrs.created_at ?? null,
|
|
1632
|
+
updatedAt: attrs.updated_at ?? null
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
_resourceToPlainDict(resource) {
|
|
1636
|
+
const attrs = resource.attributes;
|
|
1637
|
+
return {
|
|
1638
|
+
key: attrs.key,
|
|
1639
|
+
name: attrs.name,
|
|
1640
|
+
type: attrs.type,
|
|
1641
|
+
default: attrs.default,
|
|
1642
|
+
values: (attrs.values ?? []).map((v) => ({ name: v.name, value: v.value })),
|
|
1643
|
+
description: attrs.description ?? null,
|
|
1644
|
+
environments: attrs.environments ?? {}
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
_parseContextType(data) {
|
|
1648
|
+
const attrs = data.attributes ?? {};
|
|
1649
|
+
return new ContextType({
|
|
1650
|
+
id: data.id ?? "",
|
|
1651
|
+
key: attrs.key ?? "",
|
|
1652
|
+
name: attrs.name ?? "",
|
|
1653
|
+
attributes: attrs.attributes ?? {}
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
};
|
|
1657
|
+
|
|
1658
|
+
// src/ws.ts
|
|
1659
|
+
var import_ws = __toESM(require("ws"), 1);
|
|
1660
|
+
var BACKOFF_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 32e3, 6e4];
|
|
1661
|
+
var SharedWebSocket = class {
|
|
1662
|
+
_appBaseUrl;
|
|
1663
|
+
_apiKey;
|
|
1664
|
+
_listeners = /* @__PURE__ */ new Map();
|
|
1665
|
+
_connectionStatus = "disconnected";
|
|
1666
|
+
_closed = false;
|
|
1667
|
+
_ws = null;
|
|
1668
|
+
_reconnectTimer = null;
|
|
1669
|
+
_backoffIndex = 0;
|
|
1670
|
+
constructor(appBaseUrl, apiKey) {
|
|
1671
|
+
this._appBaseUrl = appBaseUrl;
|
|
1672
|
+
this._apiKey = apiKey;
|
|
1673
|
+
}
|
|
1674
|
+
// ------------------------------------------------------------------
|
|
1675
|
+
// Listener registration
|
|
1676
|
+
// ------------------------------------------------------------------
|
|
1677
|
+
/** Register a listener for a specific event type. */
|
|
1678
|
+
on(eventName, callback) {
|
|
1679
|
+
if (!this._listeners.has(eventName)) {
|
|
1680
|
+
this._listeners.set(eventName, []);
|
|
1681
|
+
}
|
|
1682
|
+
this._listeners.get(eventName).push(callback);
|
|
1683
|
+
}
|
|
1684
|
+
/** Unregister a listener for a specific event type. */
|
|
1685
|
+
off(eventName, callback) {
|
|
1686
|
+
const list = this._listeners.get(eventName);
|
|
1687
|
+
if (list) {
|
|
1688
|
+
const idx = list.indexOf(callback);
|
|
1689
|
+
if (idx !== -1) {
|
|
1690
|
+
list.splice(idx, 1);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
_dispatch(eventName, data) {
|
|
1695
|
+
const callbacks = this._listeners.get(eventName);
|
|
1696
|
+
if (callbacks) {
|
|
1697
|
+
for (const cb of [...callbacks]) {
|
|
1698
|
+
try {
|
|
1699
|
+
cb(data);
|
|
1700
|
+
} catch {
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
// ------------------------------------------------------------------
|
|
1706
|
+
// Connection status
|
|
1707
|
+
// ------------------------------------------------------------------
|
|
1708
|
+
get connectionStatus() {
|
|
1709
|
+
return this._connectionStatus;
|
|
1710
|
+
}
|
|
1711
|
+
// ------------------------------------------------------------------
|
|
1712
|
+
// Lifecycle
|
|
1713
|
+
// ------------------------------------------------------------------
|
|
1714
|
+
/** Start the WebSocket connection. */
|
|
1715
|
+
start() {
|
|
1716
|
+
this._closed = false;
|
|
1717
|
+
this._connect();
|
|
1718
|
+
}
|
|
1719
|
+
/** Stop the WebSocket connection. */
|
|
1720
|
+
stop() {
|
|
1721
|
+
this._closed = true;
|
|
1722
|
+
this._connectionStatus = "disconnected";
|
|
1723
|
+
if (this._reconnectTimer !== null) {
|
|
1724
|
+
clearTimeout(this._reconnectTimer);
|
|
1725
|
+
this._reconnectTimer = null;
|
|
1726
|
+
}
|
|
1727
|
+
if (this._ws !== null) {
|
|
1728
|
+
this._ws.close();
|
|
1729
|
+
this._ws = null;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
// ------------------------------------------------------------------
|
|
1733
|
+
// Connection internals
|
|
1734
|
+
// ------------------------------------------------------------------
|
|
1735
|
+
_buildWsUrl() {
|
|
1736
|
+
let url = this._appBaseUrl;
|
|
1737
|
+
if (url.startsWith("https://")) {
|
|
1738
|
+
url = "wss://" + url.slice("https://".length);
|
|
1739
|
+
} else if (url.startsWith("http://")) {
|
|
1740
|
+
url = "ws://" + url.slice("http://".length);
|
|
1741
|
+
} else {
|
|
1742
|
+
url = "wss://" + url;
|
|
1743
|
+
}
|
|
1744
|
+
url = url.replace(/\/$/, "");
|
|
1745
|
+
return `${url}/api/ws/v1/events?api_key=${this._apiKey}`;
|
|
1746
|
+
}
|
|
1747
|
+
_connect() {
|
|
1748
|
+
if (this._closed) return;
|
|
1749
|
+
this._connectionStatus = "connecting";
|
|
1750
|
+
const wsUrl = this._buildWsUrl();
|
|
1751
|
+
try {
|
|
1752
|
+
const ws = new import_ws.default(wsUrl);
|
|
1753
|
+
this._ws = ws;
|
|
1754
|
+
ws.on("open", () => {
|
|
1755
|
+
if (this._closed) {
|
|
1756
|
+
ws.close();
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
ws.on("message", (data) => {
|
|
1761
|
+
try {
|
|
1762
|
+
const raw = String(data);
|
|
1763
|
+
if (raw === "ping") {
|
|
1764
|
+
ws.send("pong");
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const msg = JSON.parse(raw);
|
|
1768
|
+
if (msg.type === "connected") {
|
|
1769
|
+
this._backoffIndex = 0;
|
|
1770
|
+
this._connectionStatus = "connected";
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
if (msg.type === "error") {
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
const eventName = msg.event;
|
|
1777
|
+
if (eventName) {
|
|
1778
|
+
this._dispatch(eventName, msg);
|
|
1779
|
+
}
|
|
1780
|
+
} catch {
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
ws.on("close", () => {
|
|
1784
|
+
if (!this._closed) {
|
|
1785
|
+
this._connectionStatus = "disconnected";
|
|
1786
|
+
this._scheduleReconnect();
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
ws.on("error", () => {
|
|
1790
|
+
});
|
|
1791
|
+
} catch {
|
|
1792
|
+
if (!this._closed) {
|
|
1793
|
+
this._scheduleReconnect();
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
_scheduleReconnect() {
|
|
1798
|
+
if (this._closed) return;
|
|
1799
|
+
const delay = BACKOFF_MS[Math.min(this._backoffIndex, BACKOFF_MS.length - 1)];
|
|
1800
|
+
this._backoffIndex++;
|
|
1801
|
+
this._connectionStatus = "connecting";
|
|
1802
|
+
this._reconnectTimer = setTimeout(() => {
|
|
1803
|
+
this._reconnectTimer = null;
|
|
1804
|
+
this._connect();
|
|
1805
|
+
}, delay);
|
|
1806
|
+
}
|
|
1807
|
+
};
|
|
1808
|
+
|
|
1809
|
+
// src/resolve.ts
|
|
1810
|
+
var import_node_fs = require("fs");
|
|
1811
|
+
var import_node_os = require("os");
|
|
1812
|
+
var import_node_path = require("path");
|
|
1813
|
+
var NO_API_KEY_MESSAGE = "No API key provided. Set one of:\n 1. Pass apiKey to the constructor\n 2. Set the SMPLKIT_API_KEY environment variable\n 3. Create a ~/.smplkit file with:\n [default]\n api_key = your_key_here";
|
|
1814
|
+
function resolveApiKey(explicit) {
|
|
1815
|
+
if (explicit) return explicit;
|
|
1816
|
+
const envVal = process.env.SMPLKIT_API_KEY;
|
|
1817
|
+
if (envVal) return envVal;
|
|
1818
|
+
const configPath = (0, import_node_path.join)((0, import_node_os.homedir)(), ".smplkit");
|
|
1819
|
+
try {
|
|
1820
|
+
const content = (0, import_node_fs.readFileSync)(configPath, "utf-8");
|
|
1821
|
+
let inDefaultSection = false;
|
|
1822
|
+
for (const line of content.split("\n")) {
|
|
1823
|
+
const trimmed = line.trim();
|
|
1824
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
1825
|
+
if (trimmed.startsWith("[")) {
|
|
1826
|
+
inDefaultSection = trimmed.toLowerCase() === "[default]";
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
if (inDefaultSection && trimmed.startsWith("api_key")) {
|
|
1830
|
+
const eqIndex = trimmed.indexOf("=");
|
|
1831
|
+
if (eqIndex !== -1) {
|
|
1832
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
1833
|
+
if (value) return value;
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
} catch {
|
|
1838
|
+
}
|
|
1839
|
+
throw new SmplError(NO_API_KEY_MESSAGE);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// src/client.ts
|
|
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";
|
|
1845
|
+
var SmplClient = class {
|
|
1846
|
+
/** Client for config management-plane operations. */
|
|
1847
|
+
config;
|
|
1848
|
+
/** Client for flags management and runtime operations. */
|
|
1849
|
+
flags;
|
|
1850
|
+
_wsManager = null;
|
|
1851
|
+
_apiKey;
|
|
1852
|
+
/** @internal */
|
|
1853
|
+
_environment;
|
|
1854
|
+
/** @internal */
|
|
1855
|
+
_service;
|
|
1856
|
+
_connected = false;
|
|
1857
|
+
_timeout;
|
|
1858
|
+
constructor(options = {}) {
|
|
1859
|
+
const apiKey = resolveApiKey(options.apiKey);
|
|
1860
|
+
this._apiKey = apiKey;
|
|
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);
|
|
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
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
/** Lazily create and start the shared WebSocket. @internal */
|
|
1914
|
+
_ensureWs() {
|
|
1915
|
+
if (this._wsManager === null) {
|
|
1916
|
+
this._wsManager = new SharedWebSocket(APP_BASE_URL2, this._apiKey);
|
|
1917
|
+
this._wsManager.start();
|
|
1918
|
+
}
|
|
1919
|
+
return this._wsManager;
|
|
1920
|
+
}
|
|
1921
|
+
/** Close the shared WebSocket and release resources. */
|
|
1922
|
+
close() {
|
|
1923
|
+
if (this._wsManager !== null) {
|
|
1924
|
+
this._wsManager.stop();
|
|
1925
|
+
this._wsManager = null;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
};
|
|
1929
|
+
|
|
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
|
+
};
|
|
2138
|
+
|
|
2139
|
+
// src/flags/types.ts
|
|
2140
|
+
var Context = class {
|
|
2141
|
+
type;
|
|
2142
|
+
key;
|
|
2143
|
+
name;
|
|
2144
|
+
attributes;
|
|
2145
|
+
constructor(type, key, attributes, options) {
|
|
2146
|
+
this.type = type;
|
|
2147
|
+
this.key = key;
|
|
2148
|
+
this.name = options?.name ?? null;
|
|
2149
|
+
this.attributes = { ...attributes ?? {} };
|
|
2150
|
+
}
|
|
2151
|
+
toString() {
|
|
2152
|
+
return `Context(type=${this.type}, key=${this.key}, name=${this.name})`;
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
var Rule = class {
|
|
2156
|
+
_description;
|
|
2157
|
+
_conditions = [];
|
|
2158
|
+
_value = null;
|
|
2159
|
+
_environment = null;
|
|
2160
|
+
constructor(description) {
|
|
2161
|
+
this._description = description;
|
|
2162
|
+
}
|
|
2163
|
+
/** Tag this rule with an environment key (used by `addRule`). */
|
|
2164
|
+
environment(envKey) {
|
|
2165
|
+
this._environment = envKey;
|
|
2166
|
+
return this;
|
|
2167
|
+
}
|
|
2168
|
+
/** Add a condition. Multiple calls are AND'd. */
|
|
2169
|
+
when(variable, op, value) {
|
|
2170
|
+
if (op === "contains") {
|
|
2171
|
+
this._conditions.push({ in: [value, { var: variable }] });
|
|
2172
|
+
} else {
|
|
2173
|
+
this._conditions.push({ [op]: [{ var: variable }, value] });
|
|
2174
|
+
}
|
|
2175
|
+
return this;
|
|
2176
|
+
}
|
|
2177
|
+
/** Set the value returned when this rule matches. */
|
|
2178
|
+
serve(value) {
|
|
2179
|
+
this._value = value;
|
|
2180
|
+
return this;
|
|
2181
|
+
}
|
|
2182
|
+
/** Finalize and return the rule as a plain object. */
|
|
2183
|
+
build() {
|
|
2184
|
+
let logic;
|
|
2185
|
+
if (this._conditions.length === 1) {
|
|
2186
|
+
logic = this._conditions[0];
|
|
2187
|
+
} else if (this._conditions.length > 1) {
|
|
2188
|
+
logic = { and: this._conditions };
|
|
2189
|
+
} else {
|
|
2190
|
+
logic = {};
|
|
2191
|
+
}
|
|
2192
|
+
const result = {
|
|
2193
|
+
description: this._description,
|
|
2194
|
+
logic,
|
|
2195
|
+
value: this._value
|
|
2196
|
+
};
|
|
2197
|
+
if (this._environment !== null) {
|
|
2198
|
+
result.environment = this._environment;
|
|
2199
|
+
}
|
|
2200
|
+
return result;
|
|
2201
|
+
}
|
|
2202
|
+
};
|
|
947
2203
|
// Annotate the CommonJS export names for ESM import in node:
|
|
948
2204
|
0 && (module.exports = {
|
|
2205
|
+
BoolFlagHandle,
|
|
949
2206
|
Config,
|
|
950
2207
|
ConfigClient,
|
|
951
2208
|
ConfigRuntime,
|
|
2209
|
+
Context,
|
|
2210
|
+
ContextType,
|
|
2211
|
+
Flag,
|
|
2212
|
+
FlagChangeEvent,
|
|
2213
|
+
FlagStats,
|
|
2214
|
+
FlagsClient,
|
|
2215
|
+
JsonFlagHandle,
|
|
2216
|
+
NumberFlagHandle,
|
|
2217
|
+
Rule,
|
|
2218
|
+
SharedWebSocket,
|
|
952
2219
|
SmplClient,
|
|
953
2220
|
SmplConflictError,
|
|
954
2221
|
SmplConnectionError,
|
|
955
2222
|
SmplError,
|
|
2223
|
+
SmplNotConnectedError,
|
|
956
2224
|
SmplNotFoundError,
|
|
957
2225
|
SmplTimeoutError,
|
|
958
|
-
SmplValidationError
|
|
2226
|
+
SmplValidationError,
|
|
2227
|
+
StringFlagHandle
|
|
959
2228
|
});
|
|
960
2229
|
//# sourceMappingURL=index.cjs.map
|