@replanejs/sdk 0.6.1 → 0.7.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/dist/index.js CHANGED
@@ -1,144 +1,239 @@
1
- //#region src/index.ts
2
- const SUPPORTED_REPLICATION_STREAM_RECORD_TYPES = Object.keys({
3
- config_change: true,
4
- init: true
5
- });
1
+ //#region src/error.ts
6
2
  /**
7
- * FNV-1a 32-bit hash function
3
+ * Error codes for ReplaneError
8
4
  */
9
- function fnv1a32(input) {
10
- const encoder = new TextEncoder();
11
- const bytes = encoder.encode(input);
12
- let hash = 2166136261;
13
- for (let i = 0; i < bytes.length; i++) {
14
- hash ^= bytes[i];
15
- hash = Math.imul(hash, 16777619) >>> 0;
5
+ let ReplaneErrorCode = /* @__PURE__ */ function(ReplaneErrorCode$1) {
6
+ ReplaneErrorCode$1["NotFound"] = "not_found";
7
+ ReplaneErrorCode$1["Timeout"] = "timeout";
8
+ ReplaneErrorCode$1["NetworkError"] = "network_error";
9
+ ReplaneErrorCode$1["AuthError"] = "auth_error";
10
+ ReplaneErrorCode$1["Forbidden"] = "forbidden";
11
+ ReplaneErrorCode$1["ServerError"] = "server_error";
12
+ ReplaneErrorCode$1["ClientError"] = "client_error";
13
+ ReplaneErrorCode$1["Closed"] = "closed";
14
+ ReplaneErrorCode$1["NotInitialized"] = "not_initialized";
15
+ ReplaneErrorCode$1["Unknown"] = "unknown";
16
+ return ReplaneErrorCode$1;
17
+ }({});
18
+ /**
19
+ * Custom error class for Replane SDK errors
20
+ */
21
+ var ReplaneError = class extends Error {
22
+ code;
23
+ constructor(params) {
24
+ super(params.message, { cause: params.cause });
25
+ this.name = "ReplaneError";
26
+ this.code = params.code;
16
27
  }
17
- return hash >>> 0;
28
+ };
29
+
30
+ //#endregion
31
+ //#region src/utils.ts
32
+ /**
33
+ * Returns a promise that resolves after the specified delay
34
+ *
35
+ * @param ms - Delay in milliseconds
36
+ */
37
+ async function delay(ms) {
38
+ return new Promise((resolve) => setTimeout(resolve, ms));
18
39
  }
19
40
  /**
20
- * Convert FNV-1a hash to [0, 1) for bucketing.
41
+ * Returns a promise that resolves after a delay with jitter.
42
+ * The actual delay is the average delay ± 10% (jitter = averageDelay/5).
43
+ *
44
+ * @param averageDelay - The average delay in milliseconds
21
45
  */
22
- function fnv1a32ToUnit(input) {
23
- const h = fnv1a32(input);
24
- return h / 2 ** 32;
46
+ async function retryDelay(averageDelay) {
47
+ const jitter = averageDelay / 5;
48
+ const delayMs = averageDelay + Math.random() * jitter - jitter / 2;
49
+ await delay(delayMs);
25
50
  }
26
51
  /**
27
- * Evaluate config overrides based on context (client-side implementation)
28
- * This is a simplified version without debug info
52
+ * Combines multiple abort signals into one.
53
+ * When any of the input signals is aborted, the combined signal will also be aborted.
54
+ *
55
+ * @param signals - Array of AbortSignal instances (can contain undefined/null)
56
+ * @returns An object containing the combined signal and a cleanup function
29
57
  */
30
- function evaluateOverrides(baseValue, overrides, context, logger) {
31
- for (const override of overrides) {
32
- let overrideResult = "matched";
33
- const results = override.conditions.map((c) => evaluateCondition(c, context, logger));
34
- if (results.some((r) => r === "not_matched")) overrideResult = "not_matched";
35
- else if (results.some((r) => r === "unknown")) overrideResult = "unknown";
36
- if (overrideResult === "matched") return override.value;
37
- }
38
- return baseValue;
58
+ function combineAbortSignals(signals) {
59
+ const controller = new AbortController();
60
+ const onAbort = () => {
61
+ controller.abort();
62
+ cleanUpSignals();
63
+ };
64
+ const cleanUpSignals = () => {
65
+ for (const s of signals) s?.removeEventListener("abort", onAbort);
66
+ };
67
+ for (const s of signals) s?.addEventListener("abort", onAbort, { once: true });
68
+ if (signals.some((s) => s?.aborted)) onAbort();
69
+ return {
70
+ signal: controller.signal,
71
+ cleanUpSignals
72
+ };
39
73
  }
40
74
  /**
41
- * Evaluate a single condition
75
+ * A deferred promise that can be resolved or rejected from outside.
76
+ * Useful for coordinating async operations.
42
77
  */
43
- function evaluateCondition(condition, context, logger) {
44
- const operator = condition.operator;
45
- if (operator === "and") {
46
- const results = condition.conditions.map((c) => evaluateCondition(c, context, logger));
47
- if (results.some((r) => r === "not_matched")) return "not_matched";
48
- if (results.some((r) => r === "unknown")) return "unknown";
49
- return "matched";
50
- }
51
- if (operator === "or") {
52
- const results = condition.conditions.map((c) => evaluateCondition(c, context, logger));
53
- if (results.some((r) => r === "matched")) return "matched";
54
- if (results.some((r) => r === "unknown")) return "unknown";
55
- return "not_matched";
56
- }
57
- if (operator === "not") {
58
- const result = evaluateCondition(condition.condition, context, logger);
59
- if (result === "matched") return "not_matched";
60
- if (result === "not_matched") return "matched";
61
- return "unknown";
62
- }
63
- if (operator === "segmentation") {
64
- const contextValue$1 = context[condition.property];
65
- if (contextValue$1 === void 0 || contextValue$1 === null) return "unknown";
66
- const hashInput = String(contextValue$1) + condition.seed;
67
- const unitValue = fnv1a32ToUnit(hashInput);
68
- return unitValue >= condition.fromPercentage / 100 && unitValue < condition.toPercentage / 100 ? "matched" : "not_matched";
78
+ var Deferred = class {
79
+ promise;
80
+ resolve;
81
+ reject;
82
+ constructor() {
83
+ this.promise = new Promise((resolve, reject) => {
84
+ this.resolve = resolve;
85
+ this.reject = reject;
86
+ });
69
87
  }
70
- const property = condition.property;
71
- const contextValue = context[property];
72
- const expectedValue = condition.value;
73
- if (contextValue === void 0) return "unknown";
74
- const castedValue = castToContextType(expectedValue, contextValue);
75
- switch (operator) {
76
- case "equals": return contextValue === castedValue ? "matched" : "not_matched";
77
- case "in":
78
- if (!Array.isArray(castedValue)) return "unknown";
79
- return castedValue.includes(contextValue) ? "matched" : "not_matched";
80
- case "not_in":
81
- if (!Array.isArray(castedValue)) return "unknown";
82
- return !castedValue.includes(contextValue) ? "matched" : "not_matched";
83
- case "less_than":
84
- if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue < castedValue ? "matched" : "not_matched";
85
- if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue < castedValue ? "matched" : "not_matched";
86
- return "not_matched";
87
- case "less_than_or_equal":
88
- if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue <= castedValue ? "matched" : "not_matched";
89
- if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue <= castedValue ? "matched" : "not_matched";
90
- return "not_matched";
91
- case "greater_than":
92
- if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue > castedValue ? "matched" : "not_matched";
93
- if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue > castedValue ? "matched" : "not_matched";
94
- return "not_matched";
95
- case "greater_than_or_equal":
96
- if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue >= castedValue ? "matched" : "not_matched";
97
- if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue >= castedValue ? "matched" : "not_matched";
98
- return "not_matched";
99
- default:
100
- warnNever(operator, logger, `Unexpected operator: ${operator}`);
101
- return "unknown";
88
+ };
89
+
90
+ //#endregion
91
+ //#region src/sse.ts
92
+ const SSE_DATA_PREFIX = "data:";
93
+ /**
94
+ * Fetch with timeout support
95
+ */
96
+ async function fetchWithTimeout(input, init, timeoutMs, fetchFn) {
97
+ if (!fetchFn) throw new Error("Global fetch is not available. Provide options.fetchFn.");
98
+ if (!timeoutMs) return fetchFn(input, init);
99
+ const timeoutController = new AbortController();
100
+ const t = setTimeout(() => timeoutController.abort(), timeoutMs);
101
+ const { signal } = combineAbortSignals([init.signal, timeoutController.signal]);
102
+ try {
103
+ return await fetchFn(input, {
104
+ ...init,
105
+ signal
106
+ });
107
+ } finally {
108
+ clearTimeout(t);
102
109
  }
103
110
  }
104
- function warnNever(value, logger, message) {
105
- logger.warn(message, { value });
106
- }
107
111
  /**
108
- * Cast expected value to match context value type
112
+ * Ensures the response is successful, throwing ReplaneError if not
109
113
  */
110
- function castToContextType(expectedValue, contextValue) {
111
- if (typeof contextValue === "number") {
112
- if (typeof expectedValue === "string") {
113
- const num = Number(expectedValue);
114
- return isNaN(num) ? expectedValue : num;
114
+ async function ensureSuccessfulResponse(response, message) {
115
+ if (response.status === 404) throw new ReplaneError({
116
+ message: `Not found: ${message}`,
117
+ code: ReplaneErrorCode.NotFound
118
+ });
119
+ if (response.status === 401) throw new ReplaneError({
120
+ message: `Unauthorized access: ${message}`,
121
+ code: ReplaneErrorCode.AuthError
122
+ });
123
+ if (response.status === 403) throw new ReplaneError({
124
+ message: `Forbidden access: ${message}`,
125
+ code: ReplaneErrorCode.Forbidden
126
+ });
127
+ if (!response.ok) {
128
+ let body;
129
+ try {
130
+ body = await response.text();
131
+ } catch {
132
+ body = "<unable to read response body>";
115
133
  }
116
- return expectedValue;
134
+ const code = response.status >= 500 ? ReplaneErrorCode.ServerError : response.status >= 400 ? ReplaneErrorCode.ClientError : ReplaneErrorCode.Unknown;
135
+ throw new ReplaneError({
136
+ message: `Fetch response isn't successful (${message}): ${response.status} ${response.statusText} - ${body}`,
137
+ code
138
+ });
117
139
  }
118
- if (typeof contextValue === "boolean") {
119
- if (typeof expectedValue === "string") {
120
- if (expectedValue === "true") return true;
121
- if (expectedValue === "false") return false;
140
+ }
141
+ /**
142
+ * Fetches a Server-Sent Events (SSE) stream and yields parsed events.
143
+ *
144
+ * @param params - Options for the SSE fetch
145
+ * @yields SseEvent objects containing either data or comment events
146
+ */
147
+ async function* fetchSse(params) {
148
+ const abortController = new AbortController();
149
+ const { signal, cleanUpSignals } = params.signal ? combineAbortSignals([params.signal, abortController.signal]) : {
150
+ signal: abortController.signal,
151
+ cleanUpSignals: () => {}
152
+ };
153
+ try {
154
+ const res = await fetchWithTimeout(params.url, {
155
+ method: params.method ?? "GET",
156
+ headers: {
157
+ Accept: "text/event-stream",
158
+ ...params.headers ?? {}
159
+ },
160
+ body: params.body,
161
+ signal
162
+ }, params.timeoutMs, params.fetchFn);
163
+ await ensureSuccessfulResponse(res, `SSE ${params.url}`);
164
+ const responseContentType = res.headers.get("content-type") ?? "";
165
+ if (!responseContentType.includes("text/event-stream")) throw new ReplaneError({
166
+ message: `Expected text/event-stream, got "${responseContentType}"`,
167
+ code: ReplaneErrorCode.ServerError
168
+ });
169
+ if (!res.body) throw new ReplaneError({
170
+ message: `Failed to fetch SSE ${params.url}: body is empty`,
171
+ code: ReplaneErrorCode.Unknown
172
+ });
173
+ if (params.onConnect) params.onConnect();
174
+ const decoded = res.body.pipeThrough(new TextDecoderStream());
175
+ const reader = decoded.getReader();
176
+ let buffer = "";
177
+ try {
178
+ while (true) {
179
+ const { value, done } = await reader.read();
180
+ if (done) break;
181
+ buffer += value;
182
+ const frames = buffer.split(/\r?\n\r?\n/);
183
+ buffer = frames.pop() ?? "";
184
+ for (const frame of frames) {
185
+ const dataLines = [];
186
+ let comment = null;
187
+ for (const rawLine of frame.split(/\r?\n/)) {
188
+ if (!rawLine) continue;
189
+ if (rawLine.startsWith(":")) {
190
+ comment = rawLine.slice(1);
191
+ continue;
192
+ }
193
+ if (rawLine.startsWith(SSE_DATA_PREFIX)) {
194
+ const line = rawLine.slice(SSE_DATA_PREFIX.length).replace(/^\s/, "");
195
+ dataLines.push(line);
196
+ }
197
+ }
198
+ if (dataLines.length) {
199
+ const data = dataLines.join("\n");
200
+ yield {
201
+ type: "data",
202
+ data
203
+ };
204
+ } else if (comment !== null) yield {
205
+ type: "comment",
206
+ comment
207
+ };
208
+ }
209
+ }
210
+ } finally {
211
+ try {
212
+ await reader.cancel();
213
+ } catch {}
214
+ abortController.abort();
122
215
  }
123
- if (typeof expectedValue === "number") return expectedValue !== 0;
124
- return expectedValue;
125
- }
126
- if (typeof contextValue === "string") {
127
- if (typeof expectedValue === "number" || typeof expectedValue === "boolean") return String(expectedValue);
128
- return expectedValue;
216
+ } finally {
217
+ cleanUpSignals();
129
218
  }
130
- return expectedValue;
131
- }
132
- async function delay(ms) {
133
- return new Promise((resolve) => setTimeout(resolve, ms));
134
- }
135
- async function retryDelay(averageDelay) {
136
- const jitter = averageDelay / 5;
137
- const delayMs = averageDelay + Math.random() * jitter - jitter / 2;
138
- await delay(delayMs);
139
219
  }
220
+
221
+ //#endregion
222
+ //#region src/storage.ts
223
+ const SUPPORTED_REPLICATION_STREAM_RECORD_TYPES = Object.keys({
224
+ config_change: true,
225
+ init: true
226
+ });
227
+ /**
228
+ * Remote storage implementation that connects to the Replane server
229
+ * and streams config updates via SSE.
230
+ */
140
231
  var ReplaneRemoteStorage = class {
141
232
  closeController = new AbortController();
233
+ /**
234
+ * Start a replication stream that yields config updates.
235
+ * This method never throws - it retries on failure with exponential backoff.
236
+ */
142
237
  async *startReplicationStream(options) {
143
238
  const { signal, cleanUpSignals } = combineAbortSignals([this.closeController.signal, options.signal]);
144
239
  try {
@@ -204,6 +299,9 @@ var ReplaneRemoteStorage = class {
204
299
  cleanUpSignals();
205
300
  }
206
301
  }
302
+ /**
303
+ * Close the storage and abort any active connections
304
+ */
207
305
  close() {
208
306
  this.closeController.abort();
209
307
  }
@@ -214,102 +312,224 @@ var ReplaneRemoteStorage = class {
214
312
  return `${options.baseUrl}/api${path}`;
215
313
  }
216
314
  };
217
- var ReplaneErrorCode = /* @__PURE__ */ function(ReplaneErrorCode$1) {
218
- ReplaneErrorCode$1["NotFound"] = "not_found";
219
- ReplaneErrorCode$1["Timeout"] = "timeout";
220
- ReplaneErrorCode$1["NetworkError"] = "network_error";
221
- ReplaneErrorCode$1["AuthError"] = "auth_error";
222
- ReplaneErrorCode$1["Forbidden"] = "forbidden";
223
- ReplaneErrorCode$1["ServerError"] = "server_error";
224
- ReplaneErrorCode$1["ClientError"] = "client_error";
225
- ReplaneErrorCode$1["Closed"] = "closed";
226
- ReplaneErrorCode$1["NotInitialized"] = "not_initialized";
227
- ReplaneErrorCode$1["Unknown"] = "unknown";
228
- return ReplaneErrorCode$1;
229
- }(ReplaneErrorCode || {});
230
- var ReplaneError = class extends Error {
231
- code;
232
- constructor(params) {
233
- super(params.message, { cause: params.cause });
234
- this.name = "ReplaneError";
235
- this.code = params.code;
315
+
316
+ //#endregion
317
+ //#region src/hash.ts
318
+ /**
319
+ * FNV-1a 32-bit hash function
320
+ *
321
+ * FNV (Fowler–Noll–Vo) is a non-cryptographic hash function known for its
322
+ * speed and good distribution. This implementation uses the FNV-1a variant
323
+ * which XORs before multiplying for better avalanche characteristics.
324
+ *
325
+ * @param input - The string to hash
326
+ * @returns A 32-bit unsigned integer hash value
327
+ */
328
+ function fnv1a32(input) {
329
+ const encoder = new TextEncoder();
330
+ const bytes = encoder.encode(input);
331
+ let hash = 2166136261;
332
+ for (let i = 0; i < bytes.length; i++) {
333
+ hash ^= bytes[i];
334
+ hash = Math.imul(hash, 16777619) >>> 0;
236
335
  }
237
- };
336
+ return hash >>> 0;
337
+ }
238
338
  /**
239
- * Create a Replane client bound to an SDK key.
240
- * Usage:
241
- * const client = await createReplaneClient({ sdkKey: 'your-sdk-key', baseUrl: 'https://app.replane.dev' })
242
- * const value = client.getConfig('my-config')
339
+ * Convert FNV-1a hash to [0, 1) for bucketing.
340
+ *
341
+ * This is useful for percentage-based segmentation where you need
342
+ * to deterministically assign a value to a bucket based on a string input.
343
+ *
344
+ * @param input - The string to hash
345
+ * @returns A number in the range [0, 1)
346
+ */
347
+ function fnv1a32ToUnit(input) {
348
+ const h = fnv1a32(input);
349
+ return h / 2 ** 32;
350
+ }
351
+
352
+ //#endregion
353
+ //#region src/evaluation.ts
354
+ /**
355
+ * Evaluate config overrides based on context.
356
+ * Returns the first matching override's value, or the base value if no override matches.
357
+ *
358
+ * @param baseValue - The default value to return if no override matches
359
+ * @param overrides - Array of overrides to evaluate
360
+ * @param context - The context to evaluate conditions against
361
+ * @param logger - Logger for warnings
362
+ * @returns The evaluated value
363
+ */
364
+ function evaluateOverrides(baseValue, overrides, context, logger) {
365
+ for (const override of overrides) {
366
+ let overrideResult = "matched";
367
+ const results = override.conditions.map((c) => evaluateCondition(c, context, logger));
368
+ if (results.some((r) => r === "not_matched")) overrideResult = "not_matched";
369
+ else if (results.some((r) => r === "unknown")) overrideResult = "unknown";
370
+ if (overrideResult === "matched") return override.value;
371
+ }
372
+ return baseValue;
373
+ }
374
+ /**
375
+ * Evaluate a single condition against a context.
376
+ *
377
+ * @param condition - The condition to evaluate
378
+ * @param context - The context to evaluate against
379
+ * @param logger - Logger for warnings
380
+ * @returns The evaluation result
381
+ */
382
+ function evaluateCondition(condition, context, logger) {
383
+ const operator = condition.operator;
384
+ if (operator === "and") {
385
+ const results = condition.conditions.map((c) => evaluateCondition(c, context, logger));
386
+ if (results.some((r) => r === "not_matched")) return "not_matched";
387
+ if (results.some((r) => r === "unknown")) return "unknown";
388
+ return "matched";
389
+ }
390
+ if (operator === "or") {
391
+ const results = condition.conditions.map((c) => evaluateCondition(c, context, logger));
392
+ if (results.some((r) => r === "matched")) return "matched";
393
+ if (results.some((r) => r === "unknown")) return "unknown";
394
+ return "not_matched";
395
+ }
396
+ if (operator === "not") {
397
+ const result = evaluateCondition(condition.condition, context, logger);
398
+ if (result === "matched") return "not_matched";
399
+ if (result === "not_matched") return "matched";
400
+ return "unknown";
401
+ }
402
+ if (operator === "segmentation") {
403
+ const contextValue$1 = context[condition.property];
404
+ if (contextValue$1 === void 0 || contextValue$1 === null) return "unknown";
405
+ const hashInput = String(contextValue$1) + condition.seed;
406
+ const unitValue = fnv1a32ToUnit(hashInput);
407
+ return unitValue >= condition.fromPercentage / 100 && unitValue < condition.toPercentage / 100 ? "matched" : "not_matched";
408
+ }
409
+ const property = condition.property;
410
+ const contextValue = context[property];
411
+ const expectedValue = condition.value;
412
+ if (contextValue === void 0) return "unknown";
413
+ const castedValue = castToContextType(expectedValue, contextValue);
414
+ switch (operator) {
415
+ case "equals": return contextValue === castedValue ? "matched" : "not_matched";
416
+ case "in":
417
+ if (!Array.isArray(castedValue)) return "unknown";
418
+ return castedValue.includes(contextValue) ? "matched" : "not_matched";
419
+ case "not_in":
420
+ if (!Array.isArray(castedValue)) return "unknown";
421
+ return !castedValue.includes(contextValue) ? "matched" : "not_matched";
422
+ case "less_than":
423
+ if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue < castedValue ? "matched" : "not_matched";
424
+ if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue < castedValue ? "matched" : "not_matched";
425
+ return "not_matched";
426
+ case "less_than_or_equal":
427
+ if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue <= castedValue ? "matched" : "not_matched";
428
+ if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue <= castedValue ? "matched" : "not_matched";
429
+ return "not_matched";
430
+ case "greater_than":
431
+ if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue > castedValue ? "matched" : "not_matched";
432
+ if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue > castedValue ? "matched" : "not_matched";
433
+ return "not_matched";
434
+ case "greater_than_or_equal":
435
+ if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue >= castedValue ? "matched" : "not_matched";
436
+ if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue >= castedValue ? "matched" : "not_matched";
437
+ return "not_matched";
438
+ default:
439
+ warnNever(operator, logger, `Unexpected operator: ${operator}`);
440
+ return "unknown";
441
+ }
442
+ }
443
+ /**
444
+ * Helper to warn about exhaustive check failures
243
445
  */
244
- async function createReplaneClient(sdkOptions) {
245
- const storage = new ReplaneRemoteStorage();
246
- return await _createReplaneClient(toFinalOptions(sdkOptions), storage);
446
+ function warnNever(value, logger, message) {
447
+ logger.warn(message, { value });
247
448
  }
248
449
  /**
249
- * Create a Replane client that uses in-memory storage.
250
- * Usage:
251
- * const client = createInMemoryReplaneClient({ 'my-config': 123 })
252
- * const value = client.getConfig('my-config') // 123
450
+ * Cast expected value to match context value type.
451
+ * This enables loose matching between different types (e.g., "25" matches 25).
452
+ *
453
+ * @param expectedValue - The value from the condition
454
+ * @param contextValue - The value from the context
455
+ * @returns The expected value cast to match the context value's type
253
456
  */
254
- function createInMemoryReplaneClient(initialData) {
255
- return {
256
- get: (configName) => {
257
- const config = initialData[configName];
258
- if (config === void 0) throw new ReplaneError({
259
- message: `Config not found: ${String(configName)}`,
260
- code: ReplaneErrorCode.NotFound
261
- });
262
- return config;
263
- },
264
- subscribe: () => {
265
- return () => {};
266
- },
267
- close: () => {}
268
- };
457
+ function castToContextType(expectedValue, contextValue) {
458
+ if (typeof contextValue === "number") {
459
+ if (typeof expectedValue === "string") {
460
+ const num = Number(expectedValue);
461
+ return isNaN(num) ? expectedValue : num;
462
+ }
463
+ return expectedValue;
464
+ }
465
+ if (typeof contextValue === "boolean") {
466
+ if (typeof expectedValue === "string") {
467
+ if (expectedValue === "true") return true;
468
+ if (expectedValue === "false") return false;
469
+ }
470
+ if (typeof expectedValue === "number") return expectedValue !== 0;
471
+ return expectedValue;
472
+ }
473
+ if (typeof contextValue === "string") {
474
+ if (typeof expectedValue === "number" || typeof expectedValue === "boolean") return String(expectedValue);
475
+ return expectedValue;
476
+ }
477
+ return expectedValue;
269
478
  }
270
- async function _createReplaneClient(sdkOptions, storage) {
271
- if (!sdkOptions.sdkKey) throw new Error("SDK key is required");
272
- const configs = new Map(sdkOptions.fallbacks.map((config) => [config.name, config]));
479
+
480
+ //#endregion
481
+ //#region src/client.ts
482
+ /**
483
+ * Creates the core client logic shared between createReplaneClient and restoreReplaneClient
484
+ */
485
+ function createClientCore(options) {
486
+ const { initialConfigs, context, logger, storage, streamOptions, requiredConfigs } = options;
487
+ const configs = new Map(initialConfigs.map((config) => [config.name, config]));
273
488
  const clientReady = new Deferred();
274
489
  const configSubscriptions = new Map();
275
490
  const clientSubscriptions = new Set();
276
- (async () => {
491
+ function processConfigUpdates(updatedConfigs) {
492
+ for (const config of updatedConfigs) {
493
+ configs.set(config.name, {
494
+ name: config.name,
495
+ overrides: config.overrides,
496
+ value: config.value
497
+ });
498
+ for (const callback of clientSubscriptions) callback({
499
+ name: config.name,
500
+ value: config.value
501
+ });
502
+ for (const callback of configSubscriptions.get(config.name) ?? []) callback({
503
+ name: config.name,
504
+ value: config.value
505
+ });
506
+ }
507
+ }
508
+ async function startStreaming() {
509
+ if (!storage || !streamOptions) return;
277
510
  try {
278
511
  const replicationStream = storage.startReplicationStream({
279
- ...sdkOptions,
512
+ ...streamOptions,
280
513
  getBody: () => ({
281
514
  currentConfigs: [...configs.values()].map((config) => ({
282
515
  name: config.name,
283
516
  overrides: config.overrides,
284
517
  value: config.value
285
518
  })),
286
- requiredConfigs: sdkOptions.requiredConfigs
519
+ requiredConfigs
287
520
  })
288
521
  });
289
522
  for await (const event of replicationStream) {
290
523
  const updatedConfigs = event.type === "config_change" ? [event.config] : event.configs;
291
- for (const config of updatedConfigs) {
292
- configs.set(config.name, {
293
- name: config.name,
294
- overrides: config.overrides,
295
- value: config.value
296
- });
297
- for (const callback of clientSubscriptions) callback({
298
- name: config.name,
299
- value: config.value
300
- });
301
- for (const callback of configSubscriptions.get(config.name) ?? []) callback({
302
- name: config.name,
303
- value: config.value
304
- });
305
- }
524
+ processConfigUpdates(updatedConfigs);
306
525
  clientReady.resolve();
307
526
  }
308
527
  } catch (error) {
309
- sdkOptions.logger.error("Replane: error initializing client:", error);
528
+ logger.error("Replane: error in SSE connection:", error);
310
529
  clientReady.reject(error);
530
+ throw error;
311
531
  }
312
- })();
532
+ }
313
533
  function get(configName, getConfigOptions = {}) {
314
534
  const config = configs.get(String(configName));
315
535
  if (config === void 0) throw new ReplaneError({
@@ -318,11 +538,11 @@ async function _createReplaneClient(sdkOptions, storage) {
318
538
  });
319
539
  try {
320
540
  return evaluateOverrides(config.value, config.overrides, {
321
- ...sdkOptions.context,
541
+ ...context,
322
542
  ...getConfigOptions?.context ?? {}
323
- }, sdkOptions.logger);
543
+ }, logger);
324
544
  } catch (error) {
325
- sdkOptions.logger.error(`Replane: error evaluating overrides for config ${String(configName)}:`, error);
545
+ logger.error(`Replane: error evaluating overrides for config ${String(configName)}:`, error);
326
546
  return config.value;
327
547
  }
328
548
  }
@@ -352,10 +572,157 @@ async function _createReplaneClient(sdkOptions, storage) {
352
572
  if (configSubscriptions.get(configName)?.size === 0) configSubscriptions.delete(configName);
353
573
  };
354
574
  };
355
- const close = () => storage.close();
575
+ const getSnapshot = () => ({
576
+ configs: [...configs.values()].map((config) => ({
577
+ name: config.name,
578
+ value: config.value,
579
+ overrides: config.overrides.map((override) => ({
580
+ name: override.name,
581
+ conditions: override.conditions,
582
+ value: override.value
583
+ }))
584
+ })),
585
+ context
586
+ });
587
+ const close = () => storage?.close();
588
+ const client = {
589
+ get,
590
+ subscribe,
591
+ getSnapshot,
592
+ close
593
+ };
594
+ return {
595
+ client,
596
+ configs,
597
+ startStreaming,
598
+ clientReady
599
+ };
600
+ }
601
+ /**
602
+ * Create a Replane client bound to an SDK key.
603
+ *
604
+ * @example
605
+ * ```typescript
606
+ * const client = await createReplaneClient({
607
+ * sdkKey: 'your-sdk-key',
608
+ * baseUrl: 'https://app.replane.dev'
609
+ * });
610
+ * const value = client.get('my-config');
611
+ * ```
612
+ */
613
+ async function createReplaneClient(sdkOptions) {
614
+ const storage = new ReplaneRemoteStorage();
615
+ return await createReplaneClientInternal(toFinalOptions(sdkOptions), storage);
616
+ }
617
+ /**
618
+ * Create a Replane client that uses in-memory storage.
619
+ * Useful for testing or when you have static config values.
620
+ *
621
+ * @example
622
+ * ```typescript
623
+ * const client = createInMemoryReplaneClient({ 'my-config': 123 });
624
+ * const value = client.get('my-config'); // 123
625
+ * ```
626
+ */
627
+ function createInMemoryReplaneClient(initialData) {
628
+ return {
629
+ get: (configName) => {
630
+ const config = initialData[configName];
631
+ if (config === void 0) throw new ReplaneError({
632
+ message: `Config not found: ${String(configName)}`,
633
+ code: ReplaneErrorCode.NotFound
634
+ });
635
+ return config;
636
+ },
637
+ subscribe: () => {
638
+ return () => {};
639
+ },
640
+ getSnapshot: () => ({ configs: Object.entries(initialData).map(([name, value]) => ({
641
+ name,
642
+ value,
643
+ overrides: []
644
+ })) }),
645
+ close: () => {}
646
+ };
647
+ }
648
+ /**
649
+ * Restore a Replane client from a snapshot.
650
+ * This is useful for SSR/hydration scenarios where the server has already fetched configs.
651
+ *
652
+ * @example
653
+ * ```typescript
654
+ * // On the server:
655
+ * const serverClient = await createReplaneClient({ ... });
656
+ * const snapshot = serverClient.getSnapshot();
657
+ * // Pass snapshot to client via props/serialization
658
+ *
659
+ * // On the client:
660
+ * const client = restoreReplaneClient({
661
+ * snapshot,
662
+ * connection: { sdkKey, baseUrl }
663
+ * });
664
+ * const value = client.get('my-config');
665
+ * ```
666
+ */
667
+ function restoreReplaneClient(options) {
668
+ const { snapshot, connection } = options;
669
+ const context = options.context ?? snapshot.context ?? {};
670
+ const logger = connection?.logger ?? console;
671
+ const initialConfigs = snapshot.configs.map((config) => ({
672
+ name: config.name,
673
+ value: config.value,
674
+ overrides: config.overrides
675
+ }));
676
+ let storage = null;
677
+ let streamOptions = null;
678
+ if (connection) {
679
+ storage = new ReplaneRemoteStorage();
680
+ streamOptions = {
681
+ sdkKey: connection.sdkKey,
682
+ baseUrl: connection.baseUrl.replace(/\/+$/, ""),
683
+ fetchFn: connection.fetchFn ?? globalThis.fetch.bind(globalThis),
684
+ requestTimeoutMs: connection.requestTimeoutMs ?? 2e3,
685
+ initializationTimeoutMs: 5e3,
686
+ inactivityTimeoutMs: connection.inactivityTimeoutMs ?? 3e4,
687
+ logger,
688
+ retryDelayMs: connection.retryDelayMs ?? 200,
689
+ context,
690
+ requiredConfigs: [],
691
+ fallbacks: []
692
+ };
693
+ }
694
+ const { client, startStreaming } = createClientCore({
695
+ initialConfigs,
696
+ context,
697
+ logger,
698
+ storage,
699
+ streamOptions,
700
+ requiredConfigs: []
701
+ });
702
+ if (storage && streamOptions) startStreaming().catch((error) => {
703
+ logger.error("Replane: error in restored client SSE connection:", error);
704
+ });
705
+ return client;
706
+ }
707
+ /**
708
+ * Internal function to create a Replane client with the given options and storage
709
+ */
710
+ async function createReplaneClientInternal(sdkOptions, storage) {
711
+ if (!sdkOptions.sdkKey) throw new Error("SDK key is required");
712
+ const { client, configs, startStreaming, clientReady } = createClientCore({
713
+ initialConfigs: sdkOptions.fallbacks,
714
+ context: sdkOptions.context,
715
+ logger: sdkOptions.logger,
716
+ storage,
717
+ streamOptions: sdkOptions,
718
+ requiredConfigs: sdkOptions.requiredConfigs
719
+ });
720
+ startStreaming().catch((error) => {
721
+ sdkOptions.logger.error("Replane: error initializing client:", error);
722
+ });
356
723
  const initializationTimeoutId = setTimeout(() => {
357
724
  if (sdkOptions.fallbacks.length === 0) {
358
- close();
725
+ client.close();
359
726
  clientReady.reject(new ReplaneError({
360
727
  message: "Replane client initialization timed out",
361
728
  code: ReplaneErrorCode.Timeout
@@ -365,7 +732,7 @@ async function _createReplaneClient(sdkOptions, storage) {
365
732
  const missingRequiredConfigs = [];
366
733
  for (const requiredConfigName of sdkOptions.requiredConfigs) if (!configs.has(requiredConfigName)) missingRequiredConfigs.push(requiredConfigName);
367
734
  if (missingRequiredConfigs.length > 0) {
368
- close();
735
+ client.close();
369
736
  clientReady.reject(new ReplaneError({
370
737
  message: `Required configs are missing: ${missingRequiredConfigs.join(", ")}`,
371
738
  code: ReplaneErrorCode.NotFound
@@ -376,12 +743,11 @@ async function _createReplaneClient(sdkOptions, storage) {
376
743
  }, sdkOptions.initializationTimeoutMs);
377
744
  clientReady.promise.then(() => clearTimeout(initializationTimeoutId));
378
745
  await clientReady.promise;
379
- return {
380
- get,
381
- subscribe,
382
- close
383
- };
746
+ return client;
384
747
  }
748
+ /**
749
+ * Convert user options to final options with defaults
750
+ */
385
751
  function toFinalOptions(defaults) {
386
752
  return {
387
753
  sdkKey: defaults.sdkKey,
@@ -402,149 +768,7 @@ function toFinalOptions(defaults) {
402
768
  }))
403
769
  };
404
770
  }
405
- async function fetchWithTimeout(input, init, timeoutMs, fetchFn) {
406
- if (!fetchFn) throw new Error("Global fetch is not available. Provide options.fetchFn.");
407
- if (!timeoutMs) return fetchFn(input, init);
408
- const timeoutController = new AbortController();
409
- const t = setTimeout(() => timeoutController.abort(), timeoutMs);
410
- const { signal } = combineAbortSignals([init.signal, timeoutController.signal]);
411
- try {
412
- return await fetchFn(input, {
413
- ...init,
414
- signal
415
- });
416
- } finally {
417
- clearTimeout(t);
418
- }
419
- }
420
- const SSE_DATA_PREFIX = "data:";
421
- async function* fetchSse(params) {
422
- const abortController = new AbortController();
423
- const { signal, cleanUpSignals } = params.signal ? combineAbortSignals([params.signal, abortController.signal]) : {
424
- signal: abortController.signal,
425
- cleanUpSignals: () => {}
426
- };
427
- try {
428
- const res = await fetchWithTimeout(params.url, {
429
- method: params.method ?? "GET",
430
- headers: {
431
- Accept: "text/event-stream",
432
- ...params.headers ?? {}
433
- },
434
- body: params.body,
435
- signal
436
- }, params.timeoutMs, params.fetchFn);
437
- await ensureSuccessfulResponse(res, `SSE ${params.url}`);
438
- const responseContentType = res.headers.get("content-type") ?? "";
439
- if (!responseContentType.includes("text/event-stream")) throw new ReplaneError({
440
- message: `Expected text/event-stream, got "${responseContentType}"`,
441
- code: ReplaneErrorCode.ServerError
442
- });
443
- if (!res.body) throw new ReplaneError({
444
- message: `Failed to fetch SSE ${params.url}: body is empty`,
445
- code: ReplaneErrorCode.Unknown
446
- });
447
- if (params.onConnect) params.onConnect();
448
- const decoded = res.body.pipeThrough(new TextDecoderStream());
449
- const reader = decoded.getReader();
450
- let buffer = "";
451
- try {
452
- while (true) {
453
- const { value, done } = await reader.read();
454
- if (done) break;
455
- buffer += value;
456
- const frames = buffer.split(/\r?\n\r?\n/);
457
- buffer = frames.pop() ?? "";
458
- for (const frame of frames) {
459
- const dataLines = [];
460
- let comment = null;
461
- for (const rawLine of frame.split(/\r?\n/)) {
462
- if (!rawLine) continue;
463
- if (rawLine.startsWith(":")) {
464
- comment = rawLine.slice(1);
465
- continue;
466
- }
467
- if (rawLine.startsWith(SSE_DATA_PREFIX)) {
468
- const line = rawLine.slice(SSE_DATA_PREFIX.length).replace(/^\s/, "");
469
- dataLines.push(line);
470
- }
471
- }
472
- if (dataLines.length) {
473
- const data = dataLines.join("\n");
474
- yield {
475
- type: "data",
476
- data
477
- };
478
- } else if (comment !== null) yield {
479
- type: "comment",
480
- comment
481
- };
482
- }
483
- }
484
- } finally {
485
- try {
486
- await reader.cancel();
487
- } catch {}
488
- abortController.abort();
489
- }
490
- } finally {
491
- cleanUpSignals();
492
- }
493
- }
494
- async function ensureSuccessfulResponse(response, message) {
495
- if (response.status === 404) throw new ReplaneError({
496
- message: `Not found: ${message}`,
497
- code: ReplaneErrorCode.NotFound
498
- });
499
- if (response.status === 401) throw new ReplaneError({
500
- message: `Unauthorized access: ${message}`,
501
- code: ReplaneErrorCode.AuthError
502
- });
503
- if (response.status === 403) throw new ReplaneError({
504
- message: `Forbidden access: ${message}`,
505
- code: ReplaneErrorCode.Forbidden
506
- });
507
- if (!response.ok) {
508
- let body;
509
- try {
510
- body = await response.text();
511
- } catch {
512
- body = "<unable to read response body>";
513
- }
514
- const code = response.status >= 500 ? ReplaneErrorCode.ServerError : response.status >= 400 ? ReplaneErrorCode.ClientError : ReplaneErrorCode.Unknown;
515
- throw new ReplaneError({
516
- message: `Fetch response isn't successful (${message}): ${response.status} ${response.statusText} - ${body}`,
517
- code
518
- });
519
- }
520
- }
521
- function combineAbortSignals(signals) {
522
- const controller = new AbortController();
523
- const onAbort = () => {
524
- controller.abort();
525
- cleanUpSignals();
526
- };
527
- const cleanUpSignals = () => {
528
- for (const s of signals) s?.removeEventListener("abort", onAbort);
529
- };
530
- for (const s of signals) s?.addEventListener("abort", onAbort, { once: true });
531
- if (signals.some((s) => s?.aborted)) onAbort();
532
- return {
533
- signal: controller.signal,
534
- cleanUpSignals
535
- };
536
- }
537
- var Deferred = class {
538
- promise;
539
- resolve;
540
- reject;
541
- constructor() {
542
- this.promise = new Promise((resolve, reject) => {
543
- this.resolve = resolve;
544
- this.reject = reject;
545
- });
546
- }
547
- };
548
771
 
549
772
  //#endregion
550
- export { ReplaneError, createInMemoryReplaneClient, createReplaneClient };
773
+ export { ReplaneError, ReplaneErrorCode, createInMemoryReplaneClient, createReplaneClient, restoreReplaneClient };
774
+ //# sourceMappingURL=index.js.map