@replanejs/sdk 0.5.12 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,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 {
@@ -195,8 +290,8 @@ var ReplaneRemoteStorage = class {
195
290
  });
196
291
  for await (const sseEvent of rawEvents) {
197
292
  resetInactivityTimer();
198
- if (sseEvent.type === "ping") continue;
199
- const event = JSON.parse(sseEvent.payload);
293
+ if (sseEvent.type === "comment") continue;
294
+ const event = JSON.parse(sseEvent.data);
200
295
  if (typeof event === "object" && event !== null && "type" in event && typeof event.type === "string" && SUPPORTED_REPLICATION_STREAM_RECORD_TYPES.includes(event.type)) yield event;
201
296
  }
202
297
  } finally {
@@ -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,105 +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;
335
+ }
336
+ return hash >>> 0;
337
+ }
338
+ /**
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";
236
441
  }
237
- };
442
+ }
238
443
  /**
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')
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
- version: config.version,
285
517
  value: config.value
286
518
  })),
287
- requiredConfigs: sdkOptions.requiredConfigs
519
+ requiredConfigs
288
520
  })
289
521
  });
290
522
  for await (const event of replicationStream) {
291
- const updatedConfigs = event.type === "config_change" ? [event] : event.configs;
292
- for (const config of updatedConfigs) {
293
- if (config.version <= (configs.get(config.name)?.version ?? -1)) continue;
294
- configs.set(config.name, {
295
- name: config.name,
296
- overrides: config.overrides,
297
- version: config.version,
298
- value: config.value
299
- });
300
- for (const callback of clientSubscriptions) callback({
301
- name: config.name,
302
- value: config.value
303
- });
304
- for (const callback of configSubscriptions.get(config.name) ?? []) callback({
305
- name: config.name,
306
- value: config.value
307
- });
308
- }
523
+ const updatedConfigs = event.type === "config_change" ? [event.config] : event.configs;
524
+ processConfigUpdates(updatedConfigs);
309
525
  clientReady.resolve();
310
526
  }
311
527
  } catch (error) {
312
- sdkOptions.logger.error("Replane: error initializing client:", error);
528
+ logger.error("Replane: error in SSE connection:", error);
313
529
  clientReady.reject(error);
530
+ throw error;
314
531
  }
315
- })();
532
+ }
316
533
  function get(configName, getConfigOptions = {}) {
317
534
  const config = configs.get(String(configName));
318
535
  if (config === void 0) throw new ReplaneError({
@@ -321,11 +538,11 @@ async function _createReplaneClient(sdkOptions, storage) {
321
538
  });
322
539
  try {
323
540
  return evaluateOverrides(config.value, config.overrides, {
324
- ...sdkOptions.context,
541
+ ...context,
325
542
  ...getConfigOptions?.context ?? {}
326
- }, sdkOptions.logger);
543
+ }, logger);
327
544
  } catch (error) {
328
- sdkOptions.logger.error(`Replane: error evaluating overrides for config ${String(configName)}:`, error);
545
+ logger.error(`Replane: error evaluating overrides for config ${String(configName)}:`, error);
329
546
  return config.value;
330
547
  }
331
548
  }
@@ -355,10 +572,157 @@ async function _createReplaneClient(sdkOptions, storage) {
355
572
  if (configSubscriptions.get(configName)?.size === 0) configSubscriptions.delete(configName);
356
573
  };
357
574
  };
358
- 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
+ });
359
723
  const initializationTimeoutId = setTimeout(() => {
360
724
  if (sdkOptions.fallbacks.length === 0) {
361
- close();
725
+ client.close();
362
726
  clientReady.reject(new ReplaneError({
363
727
  message: "Replane client initialization timed out",
364
728
  code: ReplaneErrorCode.Timeout
@@ -368,7 +732,7 @@ async function _createReplaneClient(sdkOptions, storage) {
368
732
  const missingRequiredConfigs = [];
369
733
  for (const requiredConfigName of sdkOptions.requiredConfigs) if (!configs.has(requiredConfigName)) missingRequiredConfigs.push(requiredConfigName);
370
734
  if (missingRequiredConfigs.length > 0) {
371
- close();
735
+ client.close();
372
736
  clientReady.reject(new ReplaneError({
373
737
  message: `Required configs are missing: ${missingRequiredConfigs.join(", ")}`,
374
738
  code: ReplaneErrorCode.NotFound
@@ -379,12 +743,11 @@ async function _createReplaneClient(sdkOptions, storage) {
379
743
  }, sdkOptions.initializationTimeoutMs);
380
744
  clientReady.promise.then(() => clearTimeout(initializationTimeoutId));
381
745
  await clientReady.promise;
382
- return {
383
- get,
384
- subscribe,
385
- close
386
- };
746
+ return client;
387
747
  }
748
+ /**
749
+ * Convert user options to final options with defaults
750
+ */
388
751
  function toFinalOptions(defaults) {
389
752
  return {
390
753
  sdkKey: defaults.sdkKey,
@@ -392,7 +755,7 @@ function toFinalOptions(defaults) {
392
755
  fetchFn: defaults.fetchFn ?? globalThis.fetch.bind(globalThis),
393
756
  requestTimeoutMs: defaults.requestTimeoutMs ?? 2e3,
394
757
  initializationTimeoutMs: defaults.initializationTimeoutMs ?? 5e3,
395
- inactivityTimeoutMs: defaults.inactivityTimeoutMs ?? 6e4,
758
+ inactivityTimeoutMs: defaults.inactivityTimeoutMs ?? 3e4,
396
759
  logger: defaults.logger ?? console,
397
760
  retryDelayMs: defaults.retryDelayMs ?? 200,
398
761
  context: { ...defaults.context ?? {} },
@@ -405,146 +768,7 @@ function toFinalOptions(defaults) {
405
768
  }))
406
769
  };
407
770
  }
408
- async function fetchWithTimeout(input, init, timeoutMs, fetchFn) {
409
- if (!fetchFn) throw new Error("Global fetch is not available. Provide options.fetchFn.");
410
- if (!timeoutMs) return fetchFn(input, init);
411
- const timeoutController = new AbortController();
412
- const t = setTimeout(() => timeoutController.abort(), timeoutMs);
413
- const { signal } = combineAbortSignals([init.signal, timeoutController.signal]);
414
- try {
415
- return await fetchFn(input, {
416
- ...init,
417
- signal
418
- });
419
- } finally {
420
- clearTimeout(t);
421
- }
422
- }
423
- const SSE_DATA_PREFIX = "data:";
424
- async function* fetchSse(params) {
425
- const abortController = new AbortController();
426
- const { signal, cleanUpSignals } = params.signal ? combineAbortSignals([params.signal, abortController.signal]) : {
427
- signal: abortController.signal,
428
- cleanUpSignals: () => {}
429
- };
430
- try {
431
- const res = await fetchWithTimeout(params.url, {
432
- method: params.method ?? "GET",
433
- headers: {
434
- Accept: "text/event-stream",
435
- ...params.headers ?? {}
436
- },
437
- body: params.body,
438
- signal
439
- }, params.timeoutMs, params.fetchFn);
440
- await ensureSuccessfulResponse(res, `SSE ${params.url}`);
441
- const responseContentType = res.headers.get("content-type") ?? "";
442
- if (!responseContentType.includes("text/event-stream")) throw new ReplaneError({
443
- message: `Expected text/event-stream, got "${responseContentType}"`,
444
- code: ReplaneErrorCode.ServerError
445
- });
446
- if (!res.body) throw new ReplaneError({
447
- message: `Failed to fetch SSE ${params.url}: body is empty`,
448
- code: ReplaneErrorCode.Unknown
449
- });
450
- if (params.onConnect) params.onConnect();
451
- const decoded = res.body.pipeThrough(new TextDecoderStream());
452
- const reader = decoded.getReader();
453
- let buffer = "";
454
- try {
455
- while (true) {
456
- const { value, done } = await reader.read();
457
- if (done) break;
458
- buffer += value;
459
- const frames = buffer.split(/\r?\n\r?\n/);
460
- buffer = frames.pop() ?? "";
461
- for (const frame of frames) {
462
- const dataLines = [];
463
- let isPing = false;
464
- for (const rawLine of frame.split(/\r?\n/)) {
465
- if (!rawLine) continue;
466
- if (rawLine.startsWith(":")) {
467
- isPing = true;
468
- continue;
469
- }
470
- if (rawLine.startsWith(SSE_DATA_PREFIX)) {
471
- const line = rawLine.slice(SSE_DATA_PREFIX.length).replace(/^\s/, "");
472
- dataLines.push(line);
473
- }
474
- }
475
- if (dataLines.length) {
476
- const payload = dataLines.join("\n");
477
- yield {
478
- type: "data",
479
- payload
480
- };
481
- } else if (isPing) yield { type: "ping" };
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