@replanejs/sdk 0.8.19 → 0.9.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
@@ -229,13 +229,13 @@ const SUPPORTED_REPLICATION_STREAM_RECORD_TYPES = Object.keys({
229
229
  * and streams config updates via SSE.
230
230
  */
231
231
  var ReplaneRemoteStorage = class {
232
- closeController = new AbortController();
232
+ disconnectController = new AbortController();
233
233
  /**
234
234
  * Start a replication stream that yields config updates.
235
235
  * This method never throws - it retries on failure with exponential backoff.
236
236
  */
237
237
  async *startReplicationStream(options) {
238
- const { signal, cleanUpSignals } = combineAbortSignals([this.closeController.signal, options.signal]);
238
+ const { signal, cleanUpSignals } = combineAbortSignals([this.disconnectController.signal, options.signal]);
239
239
  try {
240
240
  let failedAttempts = 0;
241
241
  while (!signal.aborted) try {
@@ -251,7 +251,6 @@ var ReplaneRemoteStorage = class {
251
251
  const retryDelayMs = Math.min(options.retryDelayMs * 2 ** (failedAttempts - 1), 1e4);
252
252
  if (!signal.aborted) {
253
253
  options.logger.error(`Failed to fetch project events, retrying in ${retryDelayMs}ms...`, error);
254
- options.onConnectionError?.(error);
255
254
  await retryDelay(retryDelayMs);
256
255
  }
257
256
  }
@@ -288,7 +287,6 @@ var ReplaneRemoteStorage = class {
288
287
  onConnect: () => {
289
288
  resetInactivityTimer();
290
289
  options.onConnect?.();
291
- options.onConnected?.();
292
290
  }
293
291
  });
294
292
  for await (const sseEvent of rawEvents) {
@@ -303,10 +301,11 @@ var ReplaneRemoteStorage = class {
303
301
  }
304
302
  }
305
303
  /**
306
- * Close the storage and abort any active connections
304
+ * Disconnect the storage and abort any active connections
307
305
  */
308
- close() {
309
- this.closeController.abort();
306
+ disconnect() {
307
+ this.disconnectController.abort();
308
+ this.disconnectController = new AbortController();
310
309
  }
311
310
  getAuthHeader(options) {
312
311
  return `Bearer ${options.sdkKey}`;
@@ -482,66 +481,156 @@ function castToContextType(expectedValue, contextValue) {
482
481
 
483
482
  //#endregion
484
483
  //#region src/version.ts
485
- const VERSION = "0.8.19";
484
+ const VERSION = "0.9.0";
486
485
  const DEFAULT_AGENT = `replane-js-sdk/${VERSION}`;
487
486
 
488
487
  //#endregion
489
488
  //#region src/client.ts
490
489
  /**
491
- * Creates the core client logic shared between createReplaneClient and restoreReplaneClient
490
+ * The Replane client for managing dynamic configuration.
491
+ *
492
+ * @example
493
+ * ```typescript
494
+ * // Create client with defaults
495
+ * const client = new Replane({
496
+ * defaults: { myConfig: 'defaultValue' }
497
+ * });
498
+ *
499
+ * // Use immediately (returns defaults)
500
+ * const value = client.get('myConfig');
501
+ *
502
+ * // Connect for real-time updates
503
+ * await client.connect({
504
+ * baseUrl: 'https://app.replane.dev',
505
+ * sdkKey: 'your-sdk-key'
506
+ * });
507
+ * ```
508
+ *
509
+ * @example
510
+ * ```typescript
511
+ * // SSR/Hydration: Create from snapshot
512
+ * const client = new Replane({
513
+ * snapshot: serverSnapshot
514
+ * });
515
+ * await client.connect({ baseUrl, sdkKey });
516
+ * ```
517
+ *
518
+ * @example
519
+ * ```typescript
520
+ * // In-memory mode (no connection)
521
+ * const client = new Replane({
522
+ * defaults: { feature: true, limit: 100 }
523
+ * });
524
+ * // Don't call connect() - works entirely in-memory
525
+ * ```
492
526
  */
493
- function createClientCore(options) {
494
- const { initialConfigs, context, logger, storage, streamOptions, requiredConfigs } = options;
495
- const configs = new Map(initialConfigs.map((config) => [config.name, config]));
496
- const clientReady = new Deferred();
497
- const configSubscriptions = new Map();
498
- const clientSubscriptions = new Set();
499
- function processConfigUpdates(updatedConfigs) {
500
- for (const config of updatedConfigs) {
501
- configs.set(config.name, {
502
- name: config.name,
503
- overrides: config.overrides,
504
- value: config.value
505
- });
506
- for (const callback of clientSubscriptions) callback({
507
- name: config.name,
508
- value: config.value
509
- });
510
- for (const callback of configSubscriptions.get(config.name) ?? []) callback({
511
- name: config.name,
512
- value: config.value
527
+ var Replane = class {
528
+ configs;
529
+ context;
530
+ logger;
531
+ storage = null;
532
+ configSubscriptions = new Map();
533
+ clientSubscriptions = new Set();
534
+ /**
535
+ * Create a new Replane client.
536
+ *
537
+ * The client is usable immediately after construction with defaults or snapshot data.
538
+ * Call `connect()` to establish a real-time connection for live updates.
539
+ *
540
+ * @param options - Configuration options
541
+ */
542
+ constructor(options = {}) {
543
+ this.logger = options.logger ?? console;
544
+ this.context = { ...options.context ?? {} };
545
+ const initialConfigs = [];
546
+ if (options.snapshot) for (const config of options.snapshot.configs) initialConfigs.push({
547
+ name: config.name,
548
+ value: config.value,
549
+ overrides: config.overrides
550
+ });
551
+ if (options.defaults) {
552
+ const snapshotNames = new Set(initialConfigs.map((c) => c.name));
553
+ for (const [name, value] of Object.entries(options.defaults)) if (value !== void 0 && !snapshotNames.has(name)) initialConfigs.push({
554
+ name,
555
+ value,
556
+ overrides: []
513
557
  });
514
558
  }
559
+ this.configs = new Map(initialConfigs.map((config) => [config.name, config]));
515
560
  }
516
- async function startStreaming() {
517
- if (!storage || !streamOptions) return;
518
- try {
519
- const replicationStream = storage.startReplicationStream({
520
- ...streamOptions,
521
- getBody: () => ({
522
- currentConfigs: [...configs.values()].map((config) => ({
523
- name: config.name,
524
- overrides: config.overrides,
525
- value: config.value
526
- })),
527
- requiredConfigs
528
- })
529
- });
530
- for await (const event of replicationStream) {
531
- const updatedConfigs = event.type === "config_change" ? [event.config] : event.configs;
532
- processConfigUpdates(updatedConfigs);
533
- clientReady.resolve();
534
- }
535
- } catch (error) {
536
- logger.error("Replane: error in SSE connection:", error);
537
- clientReady.reject(error);
538
- throw error;
561
+ /**
562
+ * Connect to the Replane server for real-time config updates.
563
+ *
564
+ * This method establishes an SSE connection to receive live config updates.
565
+ * If already connected, it will disconnect first and reconnect with new options.
566
+ *
567
+ * @param options - Connection options including baseUrl and sdkKey
568
+ * @returns Promise that resolves when the initial connection is established
569
+ * @throws {ReplaneError} If connection times out and no defaults are available
570
+ *
571
+ * @example
572
+ * ```typescript
573
+ * await client.connect({
574
+ * baseUrl: 'https://app.replane.dev',
575
+ * sdkKey: 'rp_xxx'
576
+ * });
577
+ * ```
578
+ */
579
+ async connect(options) {
580
+ this.disconnect();
581
+ const finalOptions = this.toFinalOptions(options);
582
+ this.storage = new ReplaneRemoteStorage();
583
+ const clientReady = new Deferred();
584
+ this.startStreaming(finalOptions, clientReady);
585
+ const timeoutId = setTimeout(() => {
586
+ this.disconnect();
587
+ clientReady.reject(new ReplaneError({
588
+ message: "Replane client connection timed out",
589
+ code: ReplaneErrorCode.Timeout
590
+ }));
591
+ }, finalOptions.connectTimeoutMs);
592
+ clientReady.promise.finally(() => clearTimeout(timeoutId));
593
+ await clientReady.promise;
594
+ }
595
+ /**
596
+ * Disconnect from the Replane server.
597
+ *
598
+ * Stops the SSE connection and cleans up resources.
599
+ * The client remains usable with cached config values.
600
+ * Can call `connect()` again to reconnect.
601
+ */
602
+ disconnect() {
603
+ if (this.storage) {
604
+ this.storage.disconnect();
605
+ this.storage = null;
539
606
  }
540
607
  }
541
- function get(configName, getConfigOptions = {}) {
542
- const config = configs.get(String(configName));
608
+ /**
609
+ * Get a config value by name.
610
+ *
611
+ * Evaluates any overrides based on the client context and per-call context.
612
+ *
613
+ * @param configName - The name of the config to retrieve
614
+ * @param options - Optional settings for this call
615
+ * @returns The config value
616
+ * @throws {ReplaneError} If config not found and no default provided
617
+ *
618
+ * @example
619
+ * ```typescript
620
+ * // Simple get
621
+ * const value = client.get('myConfig');
622
+ *
623
+ * // With default fallback
624
+ * const value = client.get('myConfig', { default: 'fallback' });
625
+ *
626
+ * // With per-call context for override evaluation
627
+ * const value = client.get('myConfig', { context: { userId: '123' } });
628
+ * ```
629
+ */
630
+ get(configName, options = {}) {
631
+ const config = this.configs.get(String(configName));
543
632
  if (config === void 0) {
544
- if ("default" in getConfigOptions) return getConfigOptions.default;
633
+ if ("default" in options) return options.default;
545
634
  throw new ReplaneError({
546
635
  message: `Config not found: ${String(configName)}`,
547
636
  code: ReplaneErrorCode.NotFound
@@ -549,15 +638,15 @@ function createClientCore(options) {
549
638
  }
550
639
  try {
551
640
  return evaluateOverrides(config.value, config.overrides, {
552
- ...context,
553
- ...getConfigOptions?.context ?? {}
554
- }, logger);
641
+ ...this.context,
642
+ ...options?.context ?? {}
643
+ }, this.logger);
555
644
  } catch (error) {
556
- logger.error(`Replane: error evaluating overrides for config ${String(configName)}:`, error);
645
+ this.logger.error(`Replane: error evaluating overrides for config ${String(configName)}:`, error);
557
646
  return config.value;
558
647
  }
559
648
  }
560
- const subscribe = (callbackOrConfigName, callbackOrUndefined) => {
649
+ subscribe(callbackOrConfigName, callbackOrUndefined) {
561
650
  let configName = void 0;
562
651
  let callback;
563
652
  if (typeof callbackOrConfigName === "function") callback = callbackOrConfigName;
@@ -571,20 +660,38 @@ function createClientCore(options) {
571
660
  originalCallback(...args);
572
661
  };
573
662
  if (configName === void 0) {
574
- clientSubscriptions.add(callback);
663
+ this.clientSubscriptions.add(callback);
575
664
  return () => {
576
- clientSubscriptions.delete(callback);
665
+ this.clientSubscriptions.delete(callback);
577
666
  };
578
667
  }
579
- if (!configSubscriptions.has(configName)) configSubscriptions.set(configName, new Set());
580
- configSubscriptions.get(configName).add(callback);
668
+ if (!this.configSubscriptions.has(configName)) this.configSubscriptions.set(configName, new Set());
669
+ this.configSubscriptions.get(configName).add(callback);
581
670
  return () => {
582
- configSubscriptions.get(configName)?.delete(callback);
583
- if (configSubscriptions.get(configName)?.size === 0) configSubscriptions.delete(configName);
671
+ this.configSubscriptions.get(configName)?.delete(callback);
672
+ if (this.configSubscriptions.get(configName)?.size === 0) this.configSubscriptions.delete(configName);
584
673
  };
585
- };
586
- const getSnapshot = () => ({
587
- configs: [...configs.values()].map((config) => ({
674
+ }
675
+ /**
676
+ * Get a serializable snapshot of the current client state.
677
+ *
678
+ * Useful for SSR/hydration scenarios where you want to pass
679
+ * configs from server to client.
680
+ *
681
+ * @returns Snapshot object that can be serialized to JSON
682
+ *
683
+ * @example
684
+ * ```typescript
685
+ * // On server
686
+ * const snapshot = client.getSnapshot();
687
+ * const json = JSON.stringify(snapshot);
688
+ *
689
+ * // On client
690
+ * const client = new Replane({ snapshot: JSON.parse(json) });
691
+ * ```
692
+ */
693
+ getSnapshot() {
694
+ return { configs: [...this.configs.values()].map((config) => ({
588
695
  name: config.name,
589
696
  value: config.value,
590
697
  overrides: config.overrides.map((override) => ({
@@ -592,196 +699,83 @@ function createClientCore(options) {
592
699
  conditions: override.conditions,
593
700
  value: override.value
594
701
  }))
595
- })),
596
- context
597
- });
598
- const close = () => storage?.close();
599
- const client = {
600
- get,
601
- subscribe,
602
- getSnapshot,
603
- close
604
- };
605
- return {
606
- client,
607
- configs,
608
- startStreaming,
609
- clientReady
610
- };
611
- }
612
- /**
613
- * Create a Replane client bound to an SDK key.
614
- *
615
- * @example
616
- * ```typescript
617
- * const client = await createReplaneClient({
618
- * sdkKey: 'your-sdk-key',
619
- * baseUrl: 'https://app.replane.dev'
620
- * });
621
- * const value = client.get('my-config');
622
- * ```
623
- */
624
- async function createReplaneClient(sdkOptions) {
625
- const storage = new ReplaneRemoteStorage();
626
- return await createReplaneClientInternal(toFinalOptions(sdkOptions), storage);
627
- }
628
- /**
629
- * Create a Replane client that uses in-memory storage.
630
- * Useful for testing or when you have static config values.
631
- *
632
- * @example
633
- * ```typescript
634
- * const client = createInMemoryReplaneClient({ 'my-config': 123 });
635
- * const value = client.get('my-config'); // 123
636
- * ```
637
- */
638
- function createInMemoryReplaneClient(initialData) {
639
- return {
640
- get: (configName, options) => {
641
- const config = initialData[configName];
642
- if (config === void 0) {
643
- if (options && "default" in options) return options.default;
644
- throw new ReplaneError({
645
- message: `Config not found: ${String(configName)}`,
646
- code: ReplaneErrorCode.NotFound
647
- });
702
+ })) };
703
+ }
704
+ /**
705
+ * Check if the client is currently connected.
706
+ */
707
+ get isConnected() {
708
+ return this.storage !== null;
709
+ }
710
+ async startStreaming(options, clientReady) {
711
+ if (!this.storage) return;
712
+ try {
713
+ const replicationStream = this.storage.startReplicationStream({
714
+ ...options,
715
+ logger: this.logger,
716
+ getBody: () => ({
717
+ currentConfigs: [...this.configs.values()].map((config) => ({
718
+ name: config.name,
719
+ overrides: config.overrides,
720
+ value: config.value
721
+ })),
722
+ requiredConfigs: []
723
+ })
724
+ });
725
+ for await (const event of replicationStream) {
726
+ const updatedConfigs = event.type === "config_change" ? [event.config] : event.configs;
727
+ this.processConfigUpdates(updatedConfigs);
728
+ clientReady.resolve();
648
729
  }
649
- return config;
650
- },
651
- subscribe: () => {
652
- return () => {};
653
- },
654
- getSnapshot: () => ({ configs: Object.entries(initialData).map(([name, value]) => ({
655
- name,
656
- value,
657
- overrides: []
658
- })) }),
659
- close: () => {}
660
- };
661
- }
662
- /**
663
- * Restore a Replane client from a snapshot.
664
- * This is useful for SSR/hydration scenarios where the server has already fetched configs.
665
- *
666
- * @example
667
- * ```typescript
668
- * // On the server:
669
- * const serverClient = await createReplaneClient({ ... });
670
- * const snapshot = serverClient.getSnapshot();
671
- * // Pass snapshot to client via props/serialization
672
- *
673
- * // On the client:
674
- * const client = restoreReplaneClient({
675
- * snapshot,
676
- * connection: { sdkKey, baseUrl }
677
- * });
678
- * const value = client.get('my-config');
679
- * ```
680
- */
681
- function restoreReplaneClient(options) {
682
- const { snapshot, connection } = options;
683
- const context = options.context ?? snapshot.context ?? {};
684
- const logger = connection?.logger ?? console;
685
- const initialConfigs = snapshot.configs.map((config) => ({
686
- name: config.name,
687
- value: config.value,
688
- overrides: config.overrides
689
- }));
690
- let storage = null;
691
- let streamOptions = null;
692
- if (connection) {
693
- storage = new ReplaneRemoteStorage();
694
- streamOptions = toFinalOptions(connection);
695
- }
696
- const { client, startStreaming } = createClientCore({
697
- initialConfigs,
698
- context,
699
- logger,
700
- storage,
701
- streamOptions,
702
- requiredConfigs: []
703
- });
704
- if (storage && streamOptions) startStreaming().catch((error) => {
705
- logger.error("Replane: error in restored client SSE connection:", error);
706
- });
707
- return client;
708
- }
709
- /**
710
- * Internal function to create a Replane client with the given options and storage
711
- */
712
- async function createReplaneClientInternal(sdkOptions, storage) {
713
- const { client, configs, startStreaming, clientReady } = createClientCore({
714
- initialConfigs: sdkOptions.defaults,
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.defaults.length === 0) {
726
- client.close();
727
- clientReady.reject(new ReplaneError({
728
- message: "Replane client initialization timed out",
729
- code: ReplaneErrorCode.Timeout
730
- }));
731
- return;
730
+ } catch (error) {
731
+ this.logger.error("Replane: error in SSE connection:", error);
732
+ clientReady.reject(error);
733
+ throw error;
732
734
  }
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;
735
+ }
736
+ processConfigUpdates(updatedConfigs) {
737
+ for (const config of updatedConfigs) {
738
+ this.configs.set(config.name, {
739
+ name: config.name,
740
+ overrides: config.overrides,
741
+ value: config.value
742
+ });
743
+ const change = {
744
+ name: config.name,
745
+ value: config.value
746
+ };
747
+ for (const callback of this.clientSubscriptions) callback(change);
748
+ for (const callback of this.configSubscriptions.get(config.name) ?? []) callback(change);
742
749
  }
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(options) {
753
- return {
754
- sdkKey: options.sdkKey ?? "",
755
- baseUrl: (options.baseUrl ?? "").replace(/\/+$/, ""),
756
- fetchFn: options.fetchFn ?? globalThis.fetch.bind(globalThis),
757
- requestTimeoutMs: options.requestTimeoutMs ?? 2e3,
758
- initializationTimeoutMs: options.initializationTimeoutMs ?? 5e3,
759
- inactivityTimeoutMs: options.inactivityTimeoutMs ?? 3e4,
760
- logger: options.logger ?? console,
761
- retryDelayMs: options.retryDelayMs ?? 200,
762
- context: { ...options.context ?? {} },
763
- requiredConfigs: Array.isArray(options.required) ? options.required.map((name) => String(name)) : Object.entries(options.required ?? {}).filter(([_, value]) => value !== void 0).map(([name]) => name),
764
- defaults: Object.entries(options.defaults ?? {}).filter(([_, value]) => value !== void 0).map(([name, value]) => ({
765
- name,
766
- overrides: [],
767
- version: -1,
768
- value
769
- })),
770
- agent: options.agent ?? DEFAULT_AGENT,
771
- onConnectionError: options.onConnectionError,
772
- onConnected: options.onConnected
773
- };
774
- }
750
+ }
751
+ toFinalOptions(options) {
752
+ return {
753
+ sdkKey: options.sdkKey,
754
+ baseUrl: (options.baseUrl ?? "").replace(/\/+$/, ""),
755
+ fetchFn: options.fetchFn ?? globalThis.fetch.bind(globalThis),
756
+ requestTimeoutMs: options.requestTimeoutMs ?? 2e3,
757
+ connectTimeoutMs: options.connectTimeoutMs ?? 5e3,
758
+ inactivityTimeoutMs: options.inactivityTimeoutMs ?? 3e4,
759
+ retryDelayMs: options.retryDelayMs ?? 200,
760
+ agent: options.agent ?? DEFAULT_AGENT
761
+ };
762
+ }
763
+ };
775
764
 
776
765
  //#endregion
777
766
  //#region src/snapshot.ts
778
767
  const clientCache = new Map();
768
+ const pendingConnections = new Map();
779
769
  function getCacheKey(options) {
780
770
  return `${options.baseUrl}:${options.sdkKey}`;
781
771
  }
782
772
  function setupCleanupTimeout(cacheKey, keepAliveMs) {
783
773
  return setTimeout(() => {
784
- clientCache.delete(cacheKey);
774
+ const cached = clientCache.get(cacheKey);
775
+ if (cached) {
776
+ cached.client.disconnect();
777
+ clientCache.delete(cacheKey);
778
+ }
785
779
  }, keepAliveMs);
786
780
  }
787
781
  /**
@@ -798,25 +792,39 @@ function setupCleanupTimeout(cacheKey, keepAliveMs) {
798
792
  * ```
799
793
  */
800
794
  async function getReplaneSnapshot(options) {
801
- const { keepAliveMs = 6e4,...clientOptions } = options;
802
- const cacheKey = getCacheKey(clientOptions);
795
+ const { keepAliveMs = 6e4, logger, context, defaults,...connectOptions } = options;
796
+ const cacheKey = getCacheKey(connectOptions);
803
797
  const cached = clientCache.get(cacheKey);
804
798
  if (cached) {
805
799
  clearTimeout(cached.timeoutId);
806
800
  cached.timeoutId = setupCleanupTimeout(cacheKey, keepAliveMs);
807
- const client$1 = await cached.clientPromise;
801
+ return cached.client.getSnapshot();
802
+ }
803
+ const pending = pendingConnections.get(cacheKey);
804
+ if (pending) {
805
+ const client$1 = await pending.promise;
808
806
  return client$1.getSnapshot();
809
807
  }
810
- const clientPromise = createReplaneClient(clientOptions);
811
- const entry = {
812
- clientPromise,
813
- timeoutId: setupCleanupTimeout(cacheKey, keepAliveMs)
814
- };
815
- clientCache.set(cacheKey, entry);
816
- const client = await clientPromise;
817
- return client.getSnapshot();
808
+ const client = new Replane({
809
+ logger,
810
+ context,
811
+ defaults
812
+ });
813
+ const connectionPromise = client.connect(connectOptions).then(() => client);
814
+ pendingConnections.set(cacheKey, { promise: connectionPromise });
815
+ try {
816
+ await connectionPromise;
817
+ const entry = {
818
+ client,
819
+ timeoutId: setupCleanupTimeout(cacheKey, keepAliveMs)
820
+ };
821
+ clientCache.set(cacheKey, entry);
822
+ return client.getSnapshot();
823
+ } finally {
824
+ pendingConnections.delete(cacheKey);
825
+ }
818
826
  }
819
827
 
820
828
  //#endregion
821
- export { ReplaneError, ReplaneErrorCode, createInMemoryReplaneClient, createReplaneClient, getReplaneSnapshot, restoreReplaneClient };
829
+ export { Replane, ReplaneError, ReplaneErrorCode, getReplaneSnapshot };
822
830
  //# sourceMappingURL=index.js.map