@replanejs/sdk 0.9.2 → 1.0.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/README.md CHANGED
@@ -1,18 +1,31 @@
1
- # Replane JavaScript SDK
1
+ <h1 align="center">Replane JavaScript SDK</h1>
2
+ <p align="center">Dynamic configuration for Node.js, Deno, Bun, and browsers.</p>
2
3
 
3
- [![npm](https://img.shields.io/npm/v/@replanejs/sdk)](https://www.npmjs.com/package/@replanejs/sdk)
4
- [![License](https://img.shields.io/github/license/replane-dev/replane-javascript)](https://github.com/replane-dev/replane-javascript/blob/main/LICENSE)
5
- [![Community](https://img.shields.io/badge/discussions-join-blue?logo=github)](https://github.com/orgs/replane-dev/discussions)
4
+ <p align="center">
5
+ <a href="https://cloud.replane.dev"><img src="https://img.shields.io/badge/Try-Replane%20Cloud-blue" alt="Replane Cloud"></a>
6
+ <a href="https://www.npmjs.com/package/@replanejs/sdk"><img src="https://img.shields.io/npm/v/@replanejs/sdk" alt="npm"></a>
7
+ <a href="https://github.com/replane-dev/replane-javascript/blob/main/LICENSE"><img src="https://img.shields.io/github/license/replane-dev/replane-javascript" alt="License"></a>
8
+ <a href="https://github.com/orgs/replane-dev/discussions"><img src="https://img.shields.io/badge/discussions-join-blue?logo=github" alt="Community"></a>
9
+ </p>
6
10
 
7
- Small TypeScript client for watching configuration values from a Replane API with realtime updates and context-based override evaluation.
11
+ <picture>
12
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/replane-dev/replane/main/public/replane-window-screenshot-dark-v1.png">
13
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/replane-dev/replane/main/public/replane-window-screenshot-light-with-border-v2.jpg">
14
+ <img alt="Replane Screenshot" src="https://raw.githubusercontent.com/replane-dev/replane/main/public/replane-window-screenshot-light-with-border-v2.jpg">
15
+ </picture>
8
16
 
9
- Part of the Replane project: [replane-dev/replane](https://github.com/replane-dev/replane).
17
+ [Replane](https://github.com/replane-dev/replane) is a dynamic configuration manager. Store feature flags, app settings, and operational config in one place—with version history, optional approvals, and realtime sync to your services. No redeploys needed.
10
18
 
11
- > Status: early. Minimal surface area on purpose. Expect small breaking tweaks until 0.1.x.
19
+ ## Why Dynamic Configuration?
12
20
 
13
- ## Why it exists
21
+ - **Feature flags** – toggle features, run A/B tests, roll out to user segments
22
+ - **Operational tuning** – adjust limits, TTLs, and timeouts without redeploying
23
+ - **Per-environment settings** – different values for production, staging, dev
24
+ - **Incident response** – instantly revert to a known-good version
25
+ - **Cross-service configuration** – share settings with realtime sync
26
+ - **Non-engineer access** – safe editing with schema validation
14
27
 
15
- You need: given a token + config name + optional context -> watch the value with realtime updates. This package does only that:
28
+ ## Features
16
29
 
17
30
  - Works in ESM and CJS (dual build)
18
31
  - Zero runtime deps (uses native `fetch` — bring a polyfill if your runtime lacks it)
@@ -33,8 +46,6 @@ yarn add @replanejs/sdk
33
46
 
34
47
  ## Quick start
35
48
 
36
- > **Important:** Each SDK key is tied to a specific project. The client can only access configs from the project that the SDK key belongs to. If you need configs from multiple projects, create separate SDK keys and initialize separate clients—one per project.
37
-
38
49
  ```ts
39
50
  import { Replane } from "@replanejs/sdk";
40
51
 
@@ -53,14 +64,17 @@ interface PasswordRequirements {
53
64
  // Create the client with optional constructor options
54
65
  const replane = new Replane<Configs>({
55
66
  context: {
56
- environment: "production",
67
+ // example context
68
+ userId: "user-123",
69
+ plan: "premium",
70
+ region: "us-east",
57
71
  },
58
72
  });
59
73
 
60
74
  // Connect to the server
61
75
  await replane.connect({
62
76
  sdkKey: process.env.REPLANE_SDK_KEY!,
63
- baseUrl: "https://replane.my-hosting.com",
77
+ baseUrl: "https://cloud.replane.dev", // or your self-hosted URL
64
78
  });
65
79
 
66
80
  // Get a config value (knows about latest updates via SSE)
@@ -79,9 +93,8 @@ const { minLength } = passwordReqs; // TypeScript knows this is PasswordRequirem
79
93
  // With context for override evaluation
80
94
  const enabled = replane.get("billing-enabled", {
81
95
  context: {
82
- userId: "user-123",
83
- plan: "premium",
84
- region: "us-east",
96
+ plan: "free",
97
+ deviceType: "mobile",
85
98
  },
86
99
  });
87
100
 
@@ -158,7 +171,7 @@ interface Configs {
158
171
  const replane = new Replane<Configs>();
159
172
  await replane.connect({
160
173
  sdkKey: "your-sdk-key",
161
- baseUrl: "https://replane.my-host.com",
174
+ baseUrl: "https://cloud.replane.dev", // or your self-hosted URL
162
175
  });
163
176
 
164
177
  // Get value without context - TypeScript knows this is boolean
@@ -176,31 +189,14 @@ const maxConnections = replane.get("max-connections", { default: 10 });
176
189
  replane.disconnect();
177
190
  ```
178
191
 
179
- ### `replane.subscribe(callback)` or `replane.subscribe(configName, callback)`
180
-
181
- Subscribe to config changes and receive real-time updates when configs are modified.
192
+ ### `replane.subscribe(configName, callback)`
182
193
 
183
- **Two overloads:**
184
-
185
- 1. **Subscribe to all config changes:**
186
-
187
- ```ts
188
- const unsubscribe = replane.subscribe((config) => {
189
- console.log(`Config ${config.name} changed to:`, config.value);
190
- });
191
- ```
192
-
193
- 2. **Subscribe to a specific config:**
194
- ```ts
195
- const unsubscribe = replane.subscribe("billing-enabled", (config) => {
196
- console.log(`billing-enabled changed to:`, config.value);
197
- });
198
- ```
194
+ Subscribe to a specific config's changes and receive real-time updates when it is modified.
199
195
 
200
196
  Parameters:
201
197
 
202
- - `callback` (function) – Function called when any config changes. Receives an object with `{ name, value }`.
203
- - `configName` (K extends keyof T) – Optional. If provided, only changes to this specific config will trigger the callback.
198
+ - `configName` (K extends keyof T) – The config to watch for changes.
199
+ - `callback` (function) – Function called when the config changes. Receives an object with `{ name, value }`.
204
200
 
205
201
  Returns a function to unsubscribe from the config changes.
206
202
 
@@ -215,12 +211,7 @@ interface Configs {
215
211
  const replane = new Replane<Configs>();
216
212
  await replane.connect({
217
213
  sdkKey: "your-sdk-key",
218
- baseUrl: "https://replane.my-host.com",
219
- });
220
-
221
- // Subscribe to all config changes
222
- const unsubscribeAll = replane.subscribe((config) => {
223
- console.log(`Config ${config.name} updated:`, config.value);
214
+ baseUrl: "https://cloud.replane.dev", // or your self-hosted URL
224
215
  });
225
216
 
226
217
  // Subscribe to a specific config
@@ -230,7 +221,6 @@ const unsubscribeFeature = replane.subscribe("feature-flag", (config) => {
230
221
  });
231
222
 
232
223
  // Later: unsubscribe when done
233
- unsubscribeAll();
234
224
  unsubscribeFeature();
235
225
 
236
226
  // Clean up when done
@@ -448,30 +438,18 @@ await replane.connect({
448
438
  baseUrl: "https://replane.my-host.com",
449
439
  });
450
440
 
451
- // Subscribe to all config changes
452
- const unsubscribeAll = replane.subscribe((config) => {
453
- console.log(`Config ${config.name} changed:`, config.value);
454
-
455
- // React to specific config changes
456
- if (config.name === "feature-flag") {
457
- console.log("Feature flag updated:", config.value);
458
- }
459
- });
460
-
461
- // Subscribe to a specific config only
441
+ // Subscribe to specific configs
462
442
  const unsubscribeFeature = replane.subscribe("feature-flag", (config) => {
463
443
  console.log("Feature flag changed:", config.value);
464
444
  // config.value is automatically typed as boolean
465
445
  });
466
446
 
467
- // Subscribe to multiple specific configs
468
447
  const unsubscribeMaxUsers = replane.subscribe("max-users", (config) => {
469
448
  console.log("Max users changed:", config.value);
470
449
  // config.value is automatically typed as number
471
450
  });
472
451
 
473
452
  // Cleanup
474
- unsubscribeAll();
475
453
  unsubscribeFeature();
476
454
  unsubscribeMaxUsers();
477
455
  replane.disconnect();
package/dist/index.cjs CHANGED
@@ -31,6 +31,20 @@ var ReplaneError = class extends Error {
31
31
  //#endregion
32
32
  //#region src/utils.ts
33
33
  /**
34
+ * Generates a random UUID using the Web Crypto API.
35
+ * Falls back to a simple implementation if crypto.randomUUID is not available.
36
+ *
37
+ * @returns A random UUID string
38
+ */
39
+ function generateClientId() {
40
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
41
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
42
+ const r = Math.random() * 16 | 0;
43
+ const v = c === "x" ? r : r & 3 | 8;
44
+ return v.toString(16);
45
+ });
46
+ }
47
+ /**
34
48
  * Returns a promise that resolves after the specified delay
35
49
  *
36
50
  * @param ms - Delay in milliseconds
@@ -482,12 +496,21 @@ function castToContextType(expectedValue, contextValue) {
482
496
 
483
497
  //#endregion
484
498
  //#region src/version.ts
485
- const VERSION = "0.9.2";
499
+ const VERSION = "1.0.0";
486
500
  const DEFAULT_AGENT = `replane-js-sdk/${VERSION}`;
487
501
 
488
502
  //#endregion
489
503
  //#region src/client.ts
490
504
  /**
505
+ * The context key for the auto-generated client ID.
506
+ * This key is automatically set by the SDK and can be used for segmentation.
507
+ * User-provided values for this key take precedence over the auto-generated value.
508
+ */
509
+ const REPLANE_CLIENT_ID_KEY = "replaneClientId";
510
+ function asReplaneHandle(replane) {
511
+ return replane;
512
+ }
513
+ /**
491
514
  * The Replane client for managing dynamic configuration.
492
515
  *
493
516
  * @example
@@ -526,12 +549,31 @@ const DEFAULT_AGENT = `replane-js-sdk/${VERSION}`;
526
549
  * ```
527
550
  */
528
551
  var Replane = class {
552
+ constructor(options = {}) {
553
+ asReplaneHandle(this)._replane = new ReplaneImpl(options);
554
+ }
555
+ connect(options) {
556
+ return asReplaneHandle(this)._replane.connect(options);
557
+ }
558
+ disconnect() {
559
+ asReplaneHandle(this)._replane.disconnect();
560
+ }
561
+ get(configName, options) {
562
+ return asReplaneHandle(this)._replane.get(configName, options);
563
+ }
564
+ subscribe(configName, callback) {
565
+ return asReplaneHandle(this)._replane.subscribe(configName, callback);
566
+ }
567
+ getSnapshot() {
568
+ return asReplaneHandle(this)._replane.getSnapshot();
569
+ }
570
+ };
571
+ var ReplaneImpl = class {
529
572
  configs;
530
573
  context;
531
574
  logger;
532
575
  storage = null;
533
576
  configSubscriptions = new Map();
534
- clientSubscriptions = new Set();
535
577
  /**
536
578
  * Create a new Replane client.
537
579
  *
@@ -542,7 +584,11 @@ var Replane = class {
542
584
  */
543
585
  constructor(options = {}) {
544
586
  this.logger = options.logger ?? console;
545
- this.context = { ...options.context ?? {} };
587
+ const autoGeneratedContext = { [REPLANE_CLIENT_ID_KEY]: generateClientId() };
588
+ this.context = {
589
+ ...autoGeneratedContext,
590
+ ...options.context ?? {}
591
+ };
546
592
  const initialConfigs = [];
547
593
  if (options.snapshot) for (const config of options.snapshot.configs) initialConfigs.push({
548
594
  name: config.name,
@@ -647,29 +693,28 @@ var Replane = class {
647
693
  return config.value;
648
694
  }
649
695
  }
650
- subscribe(callbackOrConfigName, callbackOrUndefined) {
651
- let configName = void 0;
652
- let callback;
653
- if (typeof callbackOrConfigName === "function") callback = callbackOrConfigName;
654
- else {
655
- configName = callbackOrConfigName;
656
- if (callbackOrUndefined === void 0) throw new Error("callback is required when config name is provided");
657
- callback = callbackOrUndefined;
658
- }
659
- const originalCallback = callback;
660
- callback = (...args) => {
661
- originalCallback(...args);
696
+ /**
697
+ * Subscribe to a specific config's changes.
698
+ *
699
+ * @param configName - The config to watch
700
+ * @param callback - Function called when the config changes
701
+ * @returns Unsubscribe function
702
+ *
703
+ * @example
704
+ * ```typescript
705
+ * const unsubscribe = client.subscribe('myConfig', (change) => {
706
+ * console.log(`myConfig changed to ${change.value}`);
707
+ * });
708
+ * ```
709
+ */
710
+ subscribe(configName, callback) {
711
+ const wrappedCallback = (config) => {
712
+ callback(config);
662
713
  };
663
- if (configName === void 0) {
664
- this.clientSubscriptions.add(callback);
665
- return () => {
666
- this.clientSubscriptions.delete(callback);
667
- };
668
- }
669
714
  if (!this.configSubscriptions.has(configName)) this.configSubscriptions.set(configName, new Set());
670
- this.configSubscriptions.get(configName).add(callback);
715
+ this.configSubscriptions.get(configName).add(wrappedCallback);
671
716
  return () => {
672
- this.configSubscriptions.get(configName)?.delete(callback);
717
+ this.configSubscriptions.get(configName)?.delete(wrappedCallback);
673
718
  if (this.configSubscriptions.get(configName)?.size === 0) this.configSubscriptions.delete(configName);
674
719
  };
675
720
  }
@@ -724,8 +769,13 @@ var Replane = class {
724
769
  })
725
770
  });
726
771
  for await (const event of replicationStream) {
727
- const updatedConfigs = event.type === "config_change" ? [event.config] : event.configs;
728
- this.processConfigUpdates(updatedConfigs);
772
+ if (event.type === "init") {
773
+ this.processConfigUpdates(event.configs);
774
+ this.logger.info(`Replane: initialized with ${event.configs.length} config(s)`);
775
+ } else {
776
+ this.processConfigUpdates([event.config]);
777
+ this.logger.info(`Replane: config "${event.config.name}" updated`);
778
+ }
729
779
  clientReady.resolve();
730
780
  }
731
781
  } catch (error) {
@@ -745,7 +795,6 @@ var Replane = class {
745
795
  name: config.name,
746
796
  value: config.value
747
797
  };
748
- for (const callback of this.clientSubscriptions) callback(change);
749
798
  for (const callback of this.configSubscriptions.get(config.name) ?? []) callback(change);
750
799
  }
751
800
  }
@@ -834,6 +883,250 @@ async function getReplaneSnapshot(options) {
834
883
  }
835
884
 
836
885
  //#endregion
886
+ //#region src/in-memory.ts
887
+ function asHandle(client) {
888
+ return client;
889
+ }
890
+ /**
891
+ * An in-memory Replane client for testing.
892
+ *
893
+ * This client provides the same interface as `Replane` but stores
894
+ * all configs in memory. It's useful for unit tests where you don't want
895
+ * to connect to a real Replane server.
896
+ *
897
+ * @example
898
+ * ```typescript
899
+ * // Basic usage
900
+ * const client = new InMemoryReplaneClient({
901
+ * defaults: { "feature-enabled": true, "rate-limit": 100 },
902
+ * });
903
+ * expect(client.get("feature-enabled")).toBe(true);
904
+ *
905
+ * // Update config at runtime
906
+ * client.set("feature-enabled", false);
907
+ * expect(client.get("feature-enabled")).toBe(false);
908
+ *
909
+ * // With overrides
910
+ * client.setConfig("rate-limit", 100, {
911
+ * overrides: [{
912
+ * name: "premium-users",
913
+ * conditions: [{ operator: "equals", property: "plan", value: "premium" }],
914
+ * value: 1000,
915
+ * }],
916
+ * });
917
+ * expect(client.get("rate-limit")).toBe(100);
918
+ * expect(client.get("rate-limit", { context: { plan: "premium" } })).toBe(1000);
919
+ * ```
920
+ *
921
+ * @typeParam T - Type definition for config keys and values
922
+ */
923
+ var InMemoryReplaneClient = class {
924
+ constructor(options = {}) {
925
+ asHandle(this)._impl = new InMemoryReplaneClientImpl(options);
926
+ }
927
+ /**
928
+ * Get a config value by name.
929
+ *
930
+ * Evaluates any overrides based on the client context and per-call context.
931
+ *
932
+ * @param configName - The name of the config to retrieve
933
+ * @param options - Optional settings for this call
934
+ * @returns The config value
935
+ * @throws {ReplaneError} If config not found and no default provided
936
+ */
937
+ get(configName, options) {
938
+ return asHandle(this)._impl.get(configName, options);
939
+ }
940
+ /**
941
+ * Subscribe to a specific config's changes.
942
+ *
943
+ * @param configName - The config to watch
944
+ * @param callback - Function called when the config changes
945
+ * @returns Unsubscribe function
946
+ */
947
+ subscribe(configName, callback) {
948
+ return asHandle(this)._impl.subscribe(configName, callback);
949
+ }
950
+ /**
951
+ * Get a serializable snapshot of the current client state.
952
+ *
953
+ * @returns Snapshot object that can be serialized to JSON
954
+ */
955
+ getSnapshot() {
956
+ return asHandle(this)._impl.getSnapshot();
957
+ }
958
+ /**
959
+ * Set a config value (simple form without overrides).
960
+ *
961
+ * @param name - Config name
962
+ * @param value - Config value
963
+ *
964
+ * @example
965
+ * ```typescript
966
+ * client.set("feature-enabled", true);
967
+ * client.set("rate-limit", 500);
968
+ * ```
969
+ */
970
+ set(name, value) {
971
+ asHandle(this)._impl.set(name, value);
972
+ }
973
+ /**
974
+ * Set a config with optional overrides.
975
+ *
976
+ * @param name - Config name
977
+ * @param value - Base config value
978
+ * @param options - Optional settings including overrides
979
+ *
980
+ * @example
981
+ * ```typescript
982
+ * client.setConfig("rate-limit", 100, {
983
+ * overrides: [{
984
+ * name: "premium-users",
985
+ * conditions: [
986
+ * { operator: "in", property: "plan", value: ["pro", "enterprise"] }
987
+ * ],
988
+ * value: 1000,
989
+ * }],
990
+ * });
991
+ * ```
992
+ */
993
+ setConfig(name, value, options) {
994
+ asHandle(this)._impl.setConfig(name, value, options);
995
+ }
996
+ /**
997
+ * Delete a config.
998
+ *
999
+ * @param name - Config name to delete
1000
+ * @returns True if config was deleted, false if it didn't exist
1001
+ */
1002
+ delete(name) {
1003
+ return asHandle(this)._impl.delete(name);
1004
+ }
1005
+ /**
1006
+ * Clear all configs.
1007
+ */
1008
+ clear() {
1009
+ asHandle(this)._impl.clear();
1010
+ }
1011
+ /**
1012
+ * Check if a config exists.
1013
+ *
1014
+ * @param name - Config name to check
1015
+ * @returns True if config exists
1016
+ */
1017
+ has(name) {
1018
+ return asHandle(this)._impl.has(name);
1019
+ }
1020
+ /**
1021
+ * Get all config names.
1022
+ *
1023
+ * @returns Array of config names
1024
+ */
1025
+ keys() {
1026
+ return asHandle(this)._impl.keys();
1027
+ }
1028
+ };
1029
+ var InMemoryReplaneClientImpl = class {
1030
+ configs;
1031
+ context;
1032
+ logger;
1033
+ configSubscriptions = new Map();
1034
+ constructor(options = {}) {
1035
+ this.logger = options.logger ?? console;
1036
+ this.context = options.context ?? {};
1037
+ const initialConfigs = [];
1038
+ if (options.defaults) {
1039
+ for (const [name, value] of Object.entries(options.defaults)) if (value !== void 0) initialConfigs.push({
1040
+ name,
1041
+ value,
1042
+ overrides: []
1043
+ });
1044
+ }
1045
+ this.configs = new Map(initialConfigs.map((config) => [config.name, config]));
1046
+ }
1047
+ get(configName, options = {}) {
1048
+ const config = this.configs.get(String(configName));
1049
+ if (config === void 0) {
1050
+ if ("default" in options) return options.default;
1051
+ throw new ReplaneError({
1052
+ message: `Config not found: ${String(configName)}`,
1053
+ code: ReplaneErrorCode.NotFound
1054
+ });
1055
+ }
1056
+ try {
1057
+ return evaluateOverrides(config.value, config.overrides, {
1058
+ ...this.context,
1059
+ ...options?.context ?? {}
1060
+ }, this.logger);
1061
+ } catch (error) {
1062
+ this.logger.error(`Replane: error evaluating overrides for config ${String(configName)}:`, error);
1063
+ return config.value;
1064
+ }
1065
+ }
1066
+ subscribe(configName, callback) {
1067
+ const wrappedCallback = (config) => {
1068
+ callback(config);
1069
+ };
1070
+ if (!this.configSubscriptions.has(configName)) this.configSubscriptions.set(configName, new Set());
1071
+ this.configSubscriptions.get(configName).add(wrappedCallback);
1072
+ return () => {
1073
+ this.configSubscriptions.get(configName)?.delete(wrappedCallback);
1074
+ if (this.configSubscriptions.get(configName)?.size === 0) this.configSubscriptions.delete(configName);
1075
+ };
1076
+ }
1077
+ getSnapshot() {
1078
+ return { configs: [...this.configs.values()].map((config) => ({
1079
+ name: config.name,
1080
+ value: config.value,
1081
+ overrides: config.overrides.map((override) => ({
1082
+ name: override.name,
1083
+ conditions: override.conditions,
1084
+ value: override.value
1085
+ }))
1086
+ })) };
1087
+ }
1088
+ set(name, value) {
1089
+ this.setConfig(name, value);
1090
+ }
1091
+ setConfig(name, value, options) {
1092
+ const overrides = options?.overrides ?? [];
1093
+ const config = {
1094
+ name: String(name),
1095
+ value,
1096
+ overrides
1097
+ };
1098
+ this.configs.set(String(name), config);
1099
+ this.notifySubscribers(name, value);
1100
+ }
1101
+ delete(name) {
1102
+ const existed = this.configs.has(String(name));
1103
+ this.configs.delete(String(name));
1104
+ return existed;
1105
+ }
1106
+ clear() {
1107
+ this.configs.clear();
1108
+ }
1109
+ has(name) {
1110
+ return this.configs.has(String(name));
1111
+ }
1112
+ keys() {
1113
+ return [...this.configs.keys()];
1114
+ }
1115
+ notifySubscribers(name, value) {
1116
+ const change = {
1117
+ name,
1118
+ value
1119
+ };
1120
+ for (const callback of this.configSubscriptions.get(name) ?? []) try {
1121
+ callback(change);
1122
+ } catch (error) {
1123
+ this.logger.error(`Replane: error in subscription callback for ${String(name)}:`, error);
1124
+ }
1125
+ }
1126
+ };
1127
+
1128
+ //#endregion
1129
+ exports.InMemoryReplaneClient = InMemoryReplaneClient;
837
1130
  exports.Replane = Replane;
838
1131
  exports.ReplaneError = ReplaneError;
839
1132
  exports.ReplaneErrorCode = ReplaneErrorCode;