@replanejs/sdk 0.7.5 → 0.7.8

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