@replanejs/sdk 0.5.6 → 0.5.9

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
@@ -20,11 +20,11 @@ You need: given a token + config name + optional context -> watch the value with
20
20
  ## Installation
21
21
 
22
22
  ```bash
23
- npm install replane-sdk
23
+ npm install @replanejs/sdk
24
24
  # or
25
- pnpm add replane-sdk
25
+ pnpm add @replanejs/sdk
26
26
  # or
27
- yarn add replane-sdk
27
+ yarn add @replanejs/sdk
28
28
  ```
29
29
 
30
30
  ## Quick start
@@ -32,7 +32,7 @@ yarn add replane-sdk
32
32
  > **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.
33
33
 
34
34
  ```ts
35
- import { createReplaneClient } from "replane-sdk";
35
+ import { createReplaneClient } from "@replanejs/sdk";
36
36
 
37
37
  // Define your config types
38
38
  interface Configs {
@@ -46,27 +46,27 @@ interface PasswordRequirements {
46
46
  requireSymbol: boolean;
47
47
  }
48
48
 
49
- const client = await createReplaneClient<Configs>({
49
+ const configs = await createReplaneClient<Configs>({
50
50
  // Each SDK key belongs to one project only
51
51
  sdkKey: process.env.REPLANE_SDK_KEY!,
52
52
  baseUrl: "https://replane.my-hosting.com",
53
53
  });
54
54
 
55
55
  // Get a config value (knows about latest updates via SSE)
56
- const featureFlag = client.getConfig("new-onboarding"); // Typed as boolean
56
+ const featureFlag = configs.get("new-onboarding"); // Typed as boolean
57
57
 
58
58
  if (featureFlag) {
59
59
  console.log("New onboarding enabled!");
60
60
  }
61
61
 
62
62
  // Typed config - no need to specify type again
63
- const passwordReqs = client.getConfig("password-requirements");
63
+ const passwordReqs = configs.get("password-requirements");
64
64
 
65
65
  // Use the value directly
66
66
  const { minLength } = passwordReqs; // TypeScript knows this is PasswordRequirements
67
67
 
68
68
  // With context for override evaluation
69
- const enabled = client.getConfig("billing-enabled", {
69
+ const enabled = configs.get("billing-enabled", {
70
70
  context: {
71
71
  userId: "user-123",
72
72
  plan: "premium",
@@ -79,35 +79,35 @@ if (enabled) {
79
79
  }
80
80
 
81
81
  // When done, clean up resources
82
- client.close();
82
+ configs.close();
83
83
  ```
84
84
 
85
85
  ## API
86
86
 
87
87
  ### `createReplaneClient<T>(options)`
88
88
 
89
- Returns a promise resolving to an object: `{ getConfig, close }`.
89
+ Returns a promise resolving to an object: `{ get, subscribe, close }`.
90
90
 
91
91
  Type parameter `T` defines the shape of your configs (a mapping of config names to their value types).
92
92
 
93
- `close()` stops the client and cleans up resources. After calling it, any subsequent call to `getConfig` will throw. It is safe to call multiple times (no‑op after the first call).
93
+ `close()` stops the configs client and cleans up resources. After calling it, any subsequent call to `get` will throw. It is safe to call multiple times (no‑op after the first call).
94
94
 
95
95
  #### Options
96
96
 
97
97
  - `baseUrl` (string) – Replane origin (no trailing slash needed).
98
98
  - `sdkKey` (string) – SDK key for authorization. Required. **Note:** Each SDK key is tied to a specific project and can only access configs from that project. To access configs from multiple projects, create multiple SDK keys and initialize separate client instances.
99
- - `requiredConfigs` (object) – mark specific configs as required. If any required config is missing, the client will throw an error during initialization. Optional.
100
- - `fallbackConfigs` (object) – fallback values to use if the initial request to fetch configs fails. Allows the client to start even when the API is unavailable. Use explicit `undefined` for configs without fallbacks. Optional.
101
- - `context` (object) – default context for all config evaluations. Can be overridden per-request in `getConfig()`. Optional.
99
+ - `required` (object or array) – mark specific configs as required. If any required config is missing, the client will throw an error during initialization. Can be an object with boolean values or an array of config names. Optional.
100
+ - `fallbacks` (object) – fallback values to use if the initial request to fetch configs fails. Allows the client to start even when the API is unavailable. Optional.
101
+ - `context` (object) – default context for all config evaluations. Can be overridden per-request in `get()`. Optional.
102
102
  - `fetchFn` (function) – custom fetch (e.g. `undici.fetch` or mocked fetch in tests). Optional.
103
103
  - `timeoutMs` (number) – abort the request after N ms. Default: 2000.
104
104
  - `retries` (number) – number of retry attempts on failures (5xx or network errors). Default: 2.
105
105
  - `retryDelayMs` (number) – base delay between retries in ms (a small jitter is applied). Default: 200.
106
106
  - `logger` (object) – custom logger with `debug`, `info`, `warn`, `error` methods. Default: `console`.
107
107
 
108
- ### `client.getConfig<K>(name, options?)`
108
+ ### `configs.get<K>(name, options?)`
109
109
 
110
- Gets the current config value. The client maintains an up-to-date cache that receives realtime updates via Server-Sent Events (SSE) in the background.
110
+ Gets the current config value. The configs client maintains an up-to-date cache that receives realtime updates via Server-Sent Events (SSE) in the background.
111
111
 
112
112
  Parameters:
113
113
 
@@ -119,8 +119,7 @@ Returns the config value of type `T[K]` (synchronous). The return type is automa
119
119
 
120
120
  Notes:
121
121
 
122
- - The client receives realtime updates via SSE in the background.
123
- - Values are automatically refreshed every 60 seconds as a fallback.
122
+ - The configs client receives realtime updates via SSE in the background.
124
123
  - If the config is not found, throws a `ReplaneError` with code `not_found`.
125
124
  - Context-based overrides are evaluated automatically based on context.
126
125
 
@@ -132,21 +131,81 @@ interface Configs {
132
131
  "max-connections": number;
133
132
  }
134
133
 
135
- const client = await createReplaneClient<Configs>({
134
+ const configs = await createReplaneClient<Configs>({
136
135
  sdkKey: "your-sdk-key",
137
136
  baseUrl: "https://replane.my-host.com",
138
137
  });
139
138
 
140
139
  // Get value without context - TypeScript knows this is boolean
141
- const enabled = client.getConfig("billing-enabled");
140
+ const enabled = configs.get("billing-enabled");
142
141
 
143
142
  // Get value with context for override evaluation
144
- const userEnabled = client.getConfig("billing-enabled", {
143
+ const userEnabled = configs.get("billing-enabled", {
145
144
  context: { userId: "user-123", plan: "premium" },
146
145
  });
147
146
 
148
147
  // Clean up when done
149
- client.close();
148
+ configs.close();
149
+ ```
150
+
151
+ ### `configs.subscribe(callback)` or `configs.subscribe(configName, callback)`
152
+
153
+ Subscribe to config changes and receive real-time updates when configs are modified.
154
+
155
+ **Two overloads:**
156
+
157
+ 1. **Subscribe to all config changes:**
158
+
159
+ ```ts
160
+ const unsubscribe = configs.subscribe((config) => {
161
+ console.log(`Config ${config.name} changed to:`, config.value);
162
+ });
163
+ ```
164
+
165
+ 2. **Subscribe to a specific config:**
166
+ ```ts
167
+ const unsubscribe = configs.subscribe("billing-enabled", (config) => {
168
+ console.log(`billing-enabled changed to:`, config.value);
169
+ });
170
+ ```
171
+
172
+ Parameters:
173
+
174
+ - `callback` (function) – Function called when any config changes. Receives an object with `{ name, value }`.
175
+ - `configName` (K extends keyof T) – Optional. If provided, only changes to this specific config will trigger the callback.
176
+
177
+ Returns a function to unsubscribe from the config changes.
178
+
179
+ Example:
180
+
181
+ ```ts
182
+ interface Configs {
183
+ "feature-flag": boolean;
184
+ "max-connections": number;
185
+ }
186
+
187
+ const configs = await createReplaneClient<Configs>({
188
+ sdkKey: "your-sdk-key",
189
+ baseUrl: "https://replane.my-host.com",
190
+ });
191
+
192
+ // Subscribe to all config changes
193
+ const unsubscribeAll = configs.subscribe((config) => {
194
+ console.log(`Config ${config.name} updated:`, config.value);
195
+ });
196
+
197
+ // Subscribe to a specific config
198
+ const unsubscribeFeature = configs.subscribe("feature-flag", (config) => {
199
+ console.log("Feature flag changed:", config.value);
200
+ // config.value is typed as boolean
201
+ });
202
+
203
+ // Later: unsubscribe when done
204
+ unsubscribeAll();
205
+ unsubscribeFeature();
206
+
207
+ // Clean up when done
208
+ configs.close();
150
209
  ```
151
210
 
152
211
  ### `createInMemoryReplaneClient(initialData)`
@@ -157,52 +216,52 @@ Parameters:
157
216
 
158
217
  - `initialData` (object) – map of config name to value.
159
218
 
160
- Returns a promise resolving to the same client shape as `createReplaneClient` (`{ getConfig, close }`).
219
+ Returns the same client shape as `createReplaneClient` (`{ get, subscribe, close }`).
161
220
 
162
221
  Notes:
163
222
 
164
- - `getConfig(name)` resolves to the value from `initialData`.
223
+ - `get(name)` resolves to the value from `initialData`.
165
224
  - If a name is missing, it throws a `ReplaneError` (`Config not found: <name>`).
166
225
  - The client works as usual but doesn't receive SSE updates (values remain whatever is in-memory).
167
226
 
168
227
  Example:
169
228
 
170
229
  ```ts
171
- import { createInMemoryReplaneClient } from "replane-sdk";
230
+ import { createInMemoryReplaneClient } from "@replanejs/sdk";
172
231
 
173
232
  interface Configs {
174
233
  "feature-a": boolean;
175
234
  "max-items": { value: number; ttl: number };
176
235
  }
177
236
 
178
- const client = await createInMemoryReplaneClient<Configs>({
237
+ const configs = createInMemoryReplaneClient<Configs>({
179
238
  "feature-a": true,
180
239
  "max-items": { value: 10, ttl: 3600 },
181
240
  });
182
241
 
183
- const featureA = client.getConfig("feature-a"); // TypeScript knows this is boolean
242
+ const featureA = configs.get("feature-a"); // TypeScript knows this is boolean
184
243
  console.log(featureA); // true
185
244
 
186
- const maxItems = client.getConfig("max-items"); // TypeScript knows the type
245
+ const maxItems = configs.get("max-items"); // TypeScript knows the type
187
246
  console.log(maxItems); // { value: 10, ttl: 3600 }
188
247
 
189
- client.close();
248
+ configs.close();
190
249
  ```
191
250
 
192
- ### `client.close()`
251
+ ### `configs.close()`
193
252
 
194
- Gracefully shuts down the client and cleans up resources. Subsequent method calls will throw. Use this in environments where you manage resource lifecycles explicitly (e.g. shutting down a server or worker).
253
+ Gracefully shuts down the configs client and cleans up resources. Subsequent method calls will throw. Use this in environments where you manage resource lifecycles explicitly (e.g. shutting down a server or worker).
195
254
 
196
255
  ```ts
197
256
  // During shutdown
198
- client.close();
257
+ configs.close();
199
258
  ```
200
259
 
201
260
  ### Errors
202
261
 
203
262
  `createReplaneClient` throws if the initial request to fetch configs fails with non‑2xx HTTP responses and network errors. A `ReplaneError` is thrown for HTTP failures; other errors may be thrown for network/parse issues.
204
263
 
205
- The client receives realtime updates via SSE in the background. SSE connection errors are logged and automatically retried, but don't affect `getConfig` calls (which return the last known value).
264
+ The configs client receives realtime updates via SSE in the background. SSE connection errors are logged and automatically retried, but don't affect `get` calls (which return the last known value).
206
265
 
207
266
  ## Environment notes
208
267
 
@@ -223,12 +282,12 @@ interface Configs {
223
282
  layout: LayoutConfig;
224
283
  }
225
284
 
226
- const client = await createReplaneClient<Configs>({
285
+ const configs = await createReplaneClient<Configs>({
227
286
  sdkKey: process.env.REPLANE_SDK_KEY!,
228
287
  baseUrl: "https://replane.my-host.com",
229
288
  });
230
289
 
231
- const layout = client.getConfig("layout"); // TypeScript knows this is LayoutConfig
290
+ const layout = configs.get("layout"); // TypeScript knows this is LayoutConfig
232
291
  console.log(layout); // { variant: "a", ttl: 3600 }
233
292
  ```
234
293
 
@@ -239,7 +298,7 @@ interface Configs {
239
298
  "advanced-features": boolean;
240
299
  }
241
300
 
242
- const client = await createReplaneClient<Configs>({
301
+ const configs = await createReplaneClient<Configs>({
243
302
  sdkKey: process.env.REPLANE_SDK_KEY!,
244
303
  baseUrl: "https://replane.my-host.com",
245
304
  });
@@ -247,12 +306,12 @@ const client = await createReplaneClient<Configs>({
247
306
  // Config has base value `false` but override: if `plan === "premium"` then `true`
248
307
 
249
308
  // Free user
250
- const freeUserEnabled = client.getConfig("advanced-features", {
309
+ const freeUserEnabled = configs.get("advanced-features", {
251
310
  context: { plan: "free" },
252
311
  }); // false
253
312
 
254
313
  // Premium user
255
- const premiumUserEnabled = client.getConfig("advanced-features", {
314
+ const premiumUserEnabled = configs.get("advanced-features", {
256
315
  context: { plan: "premium" },
257
316
  }); // true
258
317
  ```
@@ -264,7 +323,7 @@ interface Configs {
264
323
  "feature-flag": boolean;
265
324
  }
266
325
 
267
- const client = await createReplaneClient<Configs>({
326
+ const configs = await createReplaneClient<Configs>({
268
327
  sdkKey: process.env.REPLANE_SDK_KEY!,
269
328
  baseUrl: "https://replane.my-host.com",
270
329
  context: {
@@ -274,8 +333,8 @@ const client = await createReplaneClient<Configs>({
274
333
  });
275
334
 
276
335
  // This context is used for all configs unless overridden
277
- const value1 = client.getConfig("feature-flag"); // Uses client-level context
278
- const value2 = client.getConfig("feature-flag", {
336
+ const value1 = configs.get("feature-flag"); // Uses client-level context
337
+ const value2 = configs.get("feature-flag", {
279
338
  context: { userId: "user-321" },
280
339
  }); // Merges with client context
281
340
  ```
@@ -283,7 +342,7 @@ const value2 = client.getConfig("feature-flag", {
283
342
  ### Custom fetch (tests)
284
343
 
285
344
  ```ts
286
- const client = await createReplaneClient({
345
+ const configs = await createReplaneClient({
287
346
  sdkKey: "TKN",
288
347
  baseUrl: "https://api",
289
348
  fetchFn: mockFetch,
@@ -299,18 +358,20 @@ interface Configs {
299
358
  "optional-feature": boolean;
300
359
  }
301
360
 
302
- const client = await createReplaneClient<Configs>({
361
+ const configs = await createReplaneClient<Configs>({
303
362
  sdkKey: process.env.REPLANE_SDK_KEY!,
304
363
  baseUrl: "https://replane.my-host.com",
305
- requiredConfigs: {
364
+ required: {
306
365
  "api-key": true,
307
366
  "database-url": true,
308
367
  "optional-feature": false, // Not required
309
368
  },
310
369
  });
311
370
 
371
+ // Alternative: use an array
372
+ // required: ["api-key", "database-url"]
373
+
312
374
  // If any required config is missing, initialization will throw
313
- // Required configs that are deleted won't be removed (warning logged instead)
314
375
  ```
315
376
 
316
377
  ### Fallback configs
@@ -322,19 +383,19 @@ interface Configs {
322
383
  "timeout-ms": number;
323
384
  }
324
385
 
325
- const client = await createReplaneClient<Configs>({
386
+ const configs = await createReplaneClient<Configs>({
326
387
  sdkKey: process.env.REPLANE_SDK_KEY!,
327
388
  baseUrl: "https://replane.my-host.com",
328
- fallbackConfigs: {
389
+ fallbacks: {
329
390
  "feature-flag": false, // Use false if fetch fails
330
391
  "max-connections": 10, // Use 10 if fetch fails
331
- "timeout-ms": undefined, // No fallback - client.getConfig('timeout-ms') will throw if the initial fetch failed
392
+ "timeout-ms": 5000, // Use 5s if fetch fails
332
393
  },
333
394
  });
334
395
 
335
396
  // If the initial fetch fails, fallback values are used
336
- // Once the client connects, it will receive realtime updates
337
- const maxConnections = client.getConfig("max-connections"); // 10 (or real value)
397
+ // Once the configs client connects, it will receive realtime updates
398
+ const maxConnections = configs.get("max-connections"); // 10 (or real value)
338
399
  ```
339
400
 
340
401
  ### Multiple projects
@@ -350,20 +411,62 @@ interface ProjectBConfigs {
350
411
  "api-rate-limit": number;
351
412
  }
352
413
 
353
- // Each project needs its own SDK key and client instance
354
- const projectAClient = await createReplaneClient<ProjectAConfigs>({
414
+ // Each project needs its own SDK key and configs client instance
415
+ const projectAConfigs = await createReplaneClient<ProjectAConfigs>({
355
416
  sdkKey: process.env.PROJECT_A_SDK_KEY!,
356
417
  baseUrl: "https://replane.my-host.com",
357
418
  });
358
419
 
359
- const projectBClient = await createReplaneClient<ProjectBConfigs>({
420
+ const projectBConfigs = await createReplaneClient<ProjectBConfigs>({
360
421
  sdkKey: process.env.PROJECT_B_SDK_KEY!,
361
422
  baseUrl: "https://replane.my-host.com",
362
423
  });
363
424
 
364
- // Each client only accesses configs from its respective project
365
- const featureA = projectAClient.getConfig("feature-flag"); // boolean
366
- const featureB = projectBClient.getConfig("feature-flag"); // boolean
425
+ // Each configs client only accesses configs from its respective project
426
+ const featureA = projectAConfigs.get("feature-flag"); // boolean
427
+ const featureB = projectBConfigs.get("feature-flag"); // boolean
428
+ ```
429
+
430
+ ### Subscriptions
431
+
432
+ ```ts
433
+ interface Configs {
434
+ "feature-flag": boolean;
435
+ "max-users": number;
436
+ }
437
+
438
+ const configs = await createReplaneClient<Configs>({
439
+ sdkKey: process.env.REPLANE_SDK_KEY!,
440
+ baseUrl: "https://replane.my-host.com",
441
+ });
442
+
443
+ // Subscribe to all config changes
444
+ const unsubscribeAll = configs.subscribe((config) => {
445
+ console.log(`Config ${config.name} changed:`, config.value);
446
+
447
+ // React to specific config changes
448
+ if (config.name === "feature-flag") {
449
+ console.log("Feature flag updated:", config.value);
450
+ }
451
+ });
452
+
453
+ // Subscribe to a specific config only
454
+ const unsubscribeFeature = configs.subscribe("feature-flag", (config) => {
455
+ console.log("Feature flag changed:", config.value);
456
+ // config.value is automatically typed as boolean
457
+ });
458
+
459
+ // Subscribe to multiple specific configs
460
+ const unsubscribeMaxUsers = configs.subscribe("max-users", (config) => {
461
+ console.log("Max users changed:", config.value);
462
+ // config.value is automatically typed as number
463
+ });
464
+
465
+ // Cleanup
466
+ unsubscribeAll();
467
+ unsubscribeFeature();
468
+ unsubscribeMaxUsers();
469
+ configs.close();
367
470
  ```
368
471
 
369
472
  ## Roadmap
package/dist/index.cjs CHANGED
@@ -270,7 +270,7 @@ async function createReplaneClient(sdkOptions) {
270
270
  }
271
271
  function createInMemoryReplaneClient(initialData) {
272
272
  return {
273
- getConfig: (configName) => {
273
+ get: (configName) => {
274
274
  const config = initialData[configName];
275
275
  if (config === void 0) {
276
276
  throw new ReplaneError({
@@ -280,16 +280,22 @@ function createInMemoryReplaneClient(initialData) {
280
280
  }
281
281
  return config;
282
282
  },
283
+ subscribe: () => {
284
+ return () => {
285
+ };
286
+ },
283
287
  close: () => {
284
288
  }
285
289
  };
286
290
  }
287
291
  async function _createReplaneClient(sdkOptions, storage) {
288
292
  if (!sdkOptions.sdkKey) throw new Error("SDK key is required");
289
- let configs = new Map(
293
+ const configs = new Map(
290
294
  sdkOptions.fallbacks.map((config) => [config.name, config])
291
295
  );
292
296
  const clientReady = new Deferred();
297
+ const configSubscriptions = /* @__PURE__ */ new Map();
298
+ const clientSubscriptions = /* @__PURE__ */ new Set();
293
299
  (async () => {
294
300
  try {
295
301
  const replicationStream = storage.startReplicationStream({
@@ -305,26 +311,30 @@ async function _createReplaneClient(sdkOptions, storage) {
305
311
  })
306
312
  });
307
313
  for await (const event of replicationStream) {
308
- if (event.type === "init") {
309
- configs = new Map(event.configs.map((config) => [config.name, config]));
310
- clientReady.resolve();
311
- } else if (event.type === "config_change") {
312
- configs.set(event.configName, {
313
- name: event.configName,
314
- overrides: event.overrides,
315
- version: event.version,
316
- value: event.value
314
+ const updatedConfigs = event.type === "config_change" ? [event] : event.configs;
315
+ for (const config of updatedConfigs) {
316
+ if (config.version <= (configs.get(config.name)?.version ?? -1)) continue;
317
+ configs.set(config.name, {
318
+ name: config.name,
319
+ overrides: config.overrides,
320
+ version: config.version,
321
+ value: config.value
317
322
  });
318
- } else {
319
- warnNever(event, sdkOptions.logger, "Replane: unknown event type in event stream");
323
+ for (const callback of clientSubscriptions) {
324
+ callback({ name: config.name, value: config.value });
325
+ }
326
+ for (const callback of configSubscriptions.get(config.name) ?? []) {
327
+ callback({ name: config.name, value: config.value });
328
+ }
320
329
  }
330
+ clientReady.resolve();
321
331
  }
322
332
  } catch (error) {
323
333
  sdkOptions.logger.error("Replane: error initializing client:", error);
324
334
  clientReady.reject(error);
325
335
  }
326
336
  })();
327
- function getConfig(configName, getConfigOptions = {}) {
337
+ function get(configName, getConfigOptions = {}) {
328
338
  const config = configs.get(String(configName));
329
339
  if (config === void 0) {
330
340
  throw new ReplaneError({
@@ -347,6 +357,39 @@ async function _createReplaneClient(sdkOptions, storage) {
347
357
  return config.value;
348
358
  }
349
359
  }
360
+ const subscribe = (callbackOrConfigName, callbackOrUndefined) => {
361
+ let configName = void 0;
362
+ let callback;
363
+ if (typeof callbackOrConfigName === "function") {
364
+ callback = callbackOrConfigName;
365
+ } else {
366
+ configName = callbackOrConfigName;
367
+ if (callbackOrUndefined === void 0) {
368
+ throw new Error("callback is required when config name is provided");
369
+ }
370
+ callback = callbackOrUndefined;
371
+ }
372
+ const originalCallback = callback;
373
+ callback = (...args) => {
374
+ originalCallback(...args);
375
+ };
376
+ if (configName === void 0) {
377
+ clientSubscriptions.add(callback);
378
+ return () => {
379
+ clientSubscriptions.delete(callback);
380
+ };
381
+ }
382
+ if (!configSubscriptions.has(configName)) {
383
+ configSubscriptions.set(configName, /* @__PURE__ */ new Set());
384
+ }
385
+ configSubscriptions.get(configName).add(callback);
386
+ return () => {
387
+ configSubscriptions.get(configName)?.delete(callback);
388
+ if (configSubscriptions.get(configName)?.size === 0) {
389
+ configSubscriptions.delete(configName);
390
+ }
391
+ };
392
+ };
350
393
  const close = () => storage.close();
351
394
  const initializationTimeoutId = setTimeout(() => {
352
395
  if (sdkOptions.fallbacks.length === 0) {
@@ -376,11 +419,12 @@ async function _createReplaneClient(sdkOptions, storage) {
376
419
  return;
377
420
  }
378
421
  clientReady.resolve();
379
- }, sdkOptions.sdkInitializationTimeoutMs);
422
+ }, sdkOptions.initializationTimeoutMs);
380
423
  clientReady.promise.then(() => clearTimeout(initializationTimeoutId));
381
424
  await clientReady.promise;
382
425
  return {
383
- getConfig,
426
+ get,
427
+ subscribe,
384
428
  close
385
429
  };
386
430
  }
@@ -391,7 +435,7 @@ function toFinalOptions(defaults) {
391
435
  fetchFn: defaults.fetchFn ?? // some browsers require binding the fetch function to window
392
436
  globalThis.fetch.bind(globalThis),
393
437
  requestTimeoutMs: defaults.requestTimeoutMs ?? 2e3,
394
- sdkInitializationTimeoutMs: defaults.sdkInitializationTimeoutMs ?? 5e3,
438
+ initializationTimeoutMs: defaults.initializationTimeoutMs ?? 5e3,
395
439
  logger: defaults.logger ?? console,
396
440
  retryDelayMs: defaults.retryDelayMs ?? 200,
397
441
  context: {
package/dist/index.d.ts CHANGED
@@ -3,11 +3,18 @@ type Configs = object;
3
3
  type ReplaneContext = Record<string, unknown>;
4
4
  interface ReplaneClientOptions<T extends Configs> {
5
5
  /**
6
- * Base URL of the Replane API (no trailing slash).
6
+ * Base URL of the Replane instance (no trailing slash).
7
+ * @example
8
+ * "https://app.replane.dev"
9
+ *
10
+ * @example
11
+ * "https://replane.yourdomain.com"
7
12
  */
8
13
  baseUrl: string;
9
14
  /**
10
15
  * Project SDK key for authorization.
16
+ * @example
17
+ * "rp_XXXXXXXXX"
11
18
  */
12
19
  sdkKey: string;
13
20
  /**
@@ -23,10 +30,10 @@ interface ReplaneClientOptions<T extends Configs> {
23
30
  * Optional timeout in ms for the SDK initialization.
24
31
  * @default 5000
25
32
  */
26
- sdkInitializationTimeoutMs?: number;
33
+ initializationTimeoutMs?: number;
27
34
  /**
28
35
  * Delay between retries in ms.
29
- * @default 100
36
+ * @default 200
30
37
  */
31
38
  retryDelayMs?: number;
32
39
  /**
@@ -35,7 +42,7 @@ interface ReplaneClientOptions<T extends Configs> {
35
42
  logger?: ReplaneLogger;
36
43
  /**
37
44
  * Default context for all config evaluations.
38
- * Can be overridden per-request in `client.watchConfig()` and `watcher.getValue()`.
45
+ * Can be overridden per-request in `client.get()`.
39
46
  */
40
47
  context?: ReplaneContext;
41
48
  /**
@@ -57,7 +64,8 @@ interface ReplaneClientOptions<T extends Configs> {
57
64
  */
58
65
  required?: { [K in keyof T]: boolean } | Array<keyof T>;
59
66
  /**
60
- * Fallback configs to use if the initial request to fetch configs fails.
67
+ * Fallback values to use if the initial request to fetch configs fails.
68
+ * When provided, all configs must be specified.
61
69
  * @example
62
70
  * {
63
71
  * fallbacks: {
@@ -80,15 +88,24 @@ interface GetConfigOptions {
80
88
  */
81
89
  context?: ReplaneContext;
82
90
  }
83
- interface ConfigWatcher<T> {
84
- /** Current config value (or fallback if not found). */
85
- getValue(context?: ReplaneContext): T;
86
- /** Stop watching for changes. */
87
- close(): void;
88
- }
91
+ type MapConfig<T extends Configs> = { [K in keyof T]: {
92
+ name: K;
93
+ value: T[K];
94
+ } }[keyof T];
89
95
  interface ReplaneClient<T extends Configs> {
90
96
  /** Get a config by its name. */
91
- getConfig<K extends keyof T>(configName: K, options?: GetConfigOptions): T[K];
97
+ get<K extends keyof T>(configName: K, options?: GetConfigOptions): T[K];
98
+ /** Subscribe to config changes.
99
+ * @param callback - A function to call when an config is changed. The callback will be called with the new config value.
100
+ * @returns A function to unsubscribe from the config changes.
101
+ */
102
+ subscribe(callback: (config: MapConfig<T>) => void): () => void;
103
+ /** Subscribe to a specific config change.
104
+ * @param configName - The name of the config to subscribe to.
105
+ * @param callback - A function to call when the config is changed. The callback will be called with the new config value.
106
+ * @returns A function to unsubscribe from the config changes.
107
+ */
108
+ subscribe<K extends keyof T>(configName: K, callback: (config: MapConfig<Pick<T, K>>) => void): () => void;
92
109
  /** Close the client and clean up resources. */
93
110
  close(): void;
94
111
  }
@@ -115,4 +132,4 @@ declare function createReplaneClient<T extends Configs = Record<string, unknown>
115
132
  */
116
133
  declare function createInMemoryReplaneClient<T extends Configs = Record<string, unknown>>(initialData: T): ReplaneClient<T>;
117
134
  //#endregion
118
- export { ConfigWatcher, GetConfigOptions, ReplaneClient, ReplaneClientOptions, ReplaneContext, ReplaneError, ReplaneLogger, createInMemoryReplaneClient, createReplaneClient };
135
+ export { GetConfigOptions, ReplaneClient, ReplaneClientOptions, ReplaneContext, ReplaneError, ReplaneLogger, createInMemoryReplaneClient, createReplaneClient };
package/dist/index.js CHANGED
@@ -231,7 +231,7 @@ async function createReplaneClient(sdkOptions) {
231
231
  */
232
232
  function createInMemoryReplaneClient(initialData) {
233
233
  return {
234
- getConfig: (configName) => {
234
+ get: (configName) => {
235
235
  const config = initialData[configName];
236
236
  if (config === void 0) throw new ReplaneError({
237
237
  message: `Config not found: ${String(configName)}`,
@@ -239,13 +239,18 @@ function createInMemoryReplaneClient(initialData) {
239
239
  });
240
240
  return config;
241
241
  },
242
+ subscribe: () => {
243
+ return () => {};
244
+ },
242
245
  close: () => {}
243
246
  };
244
247
  }
245
248
  async function _createReplaneClient(sdkOptions, storage) {
246
249
  if (!sdkOptions.sdkKey) throw new Error("SDK key is required");
247
- let configs = new Map(sdkOptions.fallbacks.map((config) => [config.name, config]));
250
+ const configs = new Map(sdkOptions.fallbacks.map((config) => [config.name, config]));
248
251
  const clientReady = new Deferred();
252
+ const configSubscriptions = new Map();
253
+ const clientSubscriptions = new Set();
249
254
  (async () => {
250
255
  try {
251
256
  const replicationStream = storage.startReplicationStream({
@@ -260,22 +265,33 @@ async function _createReplaneClient(sdkOptions, storage) {
260
265
  requiredConfigs: sdkOptions.requiredConfigs
261
266
  })
262
267
  });
263
- for await (const event of replicationStream) if (event.type === "init") {
264
- configs = new Map(event.configs.map((config) => [config.name, config]));
268
+ for await (const event of replicationStream) {
269
+ const updatedConfigs = event.type === "config_change" ? [event] : event.configs;
270
+ for (const config of updatedConfigs) {
271
+ if (config.version <= (configs.get(config.name)?.version ?? -1)) continue;
272
+ configs.set(config.name, {
273
+ name: config.name,
274
+ overrides: config.overrides,
275
+ version: config.version,
276
+ value: config.value
277
+ });
278
+ for (const callback of clientSubscriptions) callback({
279
+ name: config.name,
280
+ value: config.value
281
+ });
282
+ for (const callback of configSubscriptions.get(config.name) ?? []) callback({
283
+ name: config.name,
284
+ value: config.value
285
+ });
286
+ }
265
287
  clientReady.resolve();
266
- } else if (event.type === "config_change") configs.set(event.configName, {
267
- name: event.configName,
268
- overrides: event.overrides,
269
- version: event.version,
270
- value: event.value
271
- });
272
- else warnNever(event, sdkOptions.logger, "Replane: unknown event type in event stream");
288
+ }
273
289
  } catch (error) {
274
290
  sdkOptions.logger.error("Replane: error initializing client:", error);
275
291
  clientReady.reject(error);
276
292
  }
277
293
  })();
278
- function getConfig(configName, getConfigOptions = {}) {
294
+ function get(configName, getConfigOptions = {}) {
279
295
  const config = configs.get(String(configName));
280
296
  if (config === void 0) throw new ReplaneError({
281
297
  message: `Config not found: ${String(configName)}`,
@@ -291,6 +307,32 @@ async function _createReplaneClient(sdkOptions, storage) {
291
307
  return config.value;
292
308
  }
293
309
  }
310
+ const subscribe = (callbackOrConfigName, callbackOrUndefined) => {
311
+ let configName = void 0;
312
+ let callback;
313
+ if (typeof callbackOrConfigName === "function") callback = callbackOrConfigName;
314
+ else {
315
+ configName = callbackOrConfigName;
316
+ if (callbackOrUndefined === void 0) throw new Error("callback is required when config name is provided");
317
+ callback = callbackOrUndefined;
318
+ }
319
+ const originalCallback = callback;
320
+ callback = (...args) => {
321
+ originalCallback(...args);
322
+ };
323
+ if (configName === void 0) {
324
+ clientSubscriptions.add(callback);
325
+ return () => {
326
+ clientSubscriptions.delete(callback);
327
+ };
328
+ }
329
+ if (!configSubscriptions.has(configName)) configSubscriptions.set(configName, new Set());
330
+ configSubscriptions.get(configName).add(callback);
331
+ return () => {
332
+ configSubscriptions.get(configName)?.delete(callback);
333
+ if (configSubscriptions.get(configName)?.size === 0) configSubscriptions.delete(configName);
334
+ };
335
+ };
294
336
  const close = () => storage.close();
295
337
  const initializationTimeoutId = setTimeout(() => {
296
338
  if (sdkOptions.fallbacks.length === 0) {
@@ -312,11 +354,12 @@ async function _createReplaneClient(sdkOptions, storage) {
312
354
  return;
313
355
  }
314
356
  clientReady.resolve();
315
- }, sdkOptions.sdkInitializationTimeoutMs);
357
+ }, sdkOptions.initializationTimeoutMs);
316
358
  clientReady.promise.then(() => clearTimeout(initializationTimeoutId));
317
359
  await clientReady.promise;
318
360
  return {
319
- getConfig,
361
+ get,
362
+ subscribe,
320
363
  close
321
364
  };
322
365
  }
@@ -326,7 +369,7 @@ function toFinalOptions(defaults) {
326
369
  baseUrl: defaults.baseUrl.replace(/\/+$/, ""),
327
370
  fetchFn: defaults.fetchFn ?? globalThis.fetch.bind(globalThis),
328
371
  requestTimeoutMs: defaults.requestTimeoutMs ?? 2e3,
329
- sdkInitializationTimeoutMs: defaults.sdkInitializationTimeoutMs ?? 5e3,
372
+ initializationTimeoutMs: defaults.initializationTimeoutMs ?? 5e3,
330
373
  logger: defaults.logger ?? console,
331
374
  retryDelayMs: defaults.retryDelayMs ?? 200,
332
375
  context: { ...defaults.context ?? {} },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replanejs/sdk",
3
- "version": "0.5.6",
3
+ "version": "0.5.9",
4
4
  "description": "Replane JavaScript SDK.",
5
5
  "type": "module",
6
6
  "license": "MIT",