launchdarkly-js-sdk-common 5.7.0 → 5.8.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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "5.7.0"
2
+ ".": "5.8.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to the `launchdarkly-js-sdk-common` package will be documented in this file. Changes that affect the dependent SDKs such as `launchdarkly-js-client-sdk` should also be logged in those projects, in the next release that uses the updated version of this package. This project adheres to [Semantic Versioning](http://semver.org).
4
4
 
5
+ ## [5.8.0](https://github.com/launchdarkly/js-sdk-common/compare/5.7.1...5.8.0) (2025-09-05)
6
+
7
+
8
+ ### Features
9
+
10
+ * Add experimental debug override functionality. ([#132](https://github.com/launchdarkly/js-sdk-common/issues/132)) ([8b2f757](https://github.com/launchdarkly/js-sdk-common/commit/8b2f757e9e070226a10f32dcf943501a8f8f1e4a))
11
+
12
+ ## [5.7.1](https://github.com/launchdarkly/js-sdk-common/compare/5.7.0...5.7.1) (2025-05-30)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * updating identify code documentation ([82c6071](https://github.com/launchdarkly/js-sdk-common/commit/82c6071760cccbe3000a30f46fb19e169ed8573e))
18
+
5
19
  ## [5.7.0](https://github.com/launchdarkly/js-sdk-common/compare/5.6.0...5.7.0) (2025-05-22)
6
20
 
7
21
 
package/CODEOWNERS CHANGED
@@ -1,2 +1,2 @@
1
1
  # Repository Maintainers
2
- * @launchdarkly/team-sdk
2
+ * @launchdarkly/team-sdk-js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchdarkly-js-sdk-common",
3
- "version": "5.7.0",
3
+ "version": "5.8.0",
4
4
  "description": "LaunchDarkly SDK for JavaScript - common code",
5
5
  "author": "LaunchDarkly <team@launchdarkly.com>",
6
6
  "license": "Apache-2.0",
@@ -44,8 +44,8 @@
44
44
  "launchdarkly-js-test-helpers": "1.1.0",
45
45
  "prettier": "1.19.1",
46
46
  "readline-sync": "^1.4.9",
47
- "typescript": "~5.4.5",
48
- "typedoc": "^0.25.13"
47
+ "typedoc": "^0.25.13",
48
+ "typescript": "~5.4.5"
49
49
  },
50
50
  "dependencies": {
51
51
  "base64-js": "^1.3.0",
@@ -0,0 +1,144 @@
1
+ const utils = require('./utils');
2
+
3
+ /**
4
+ * FlagStore - Centralized flag store and access point for all feature flags
5
+ *
6
+ * This module manages two types of feature flags:
7
+ * 1. Regular flags - Retrieved from LaunchDarkly servers or bootstrap data
8
+ * 2. Override flags - Local overrides for debugging/testing
9
+ *
10
+ * When a flag is requested:
11
+ * - If an override exists for that flag, the override value is returned
12
+ * - Otherwise, the regular flag value is returned
13
+ */
14
+ function FlagStore() {
15
+ let flags = {};
16
+ // The flag overrides are set lazily to allow bypassing property checks when no overrides are present.
17
+ let flagOverrides;
18
+
19
+ /**
20
+ * Gets a single flag by key, with overrides taking precedence over regular flags
21
+ * @param {string} key The flag key to retrieve
22
+ * @returns {Object|null} The flag object or null if not found
23
+ */
24
+ function get(key) {
25
+ // Check overrides first, then real flags
26
+ if (flagOverrides && utils.objectHasOwnProperty(flagOverrides, key) && flagOverrides[key]) {
27
+ return flagOverrides[key];
28
+ }
29
+
30
+ if (flags && utils.objectHasOwnProperty(flags, key) && flags[key] && !flags[key].deleted) {
31
+ return flags[key];
32
+ }
33
+
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Gets all flags with overrides applied
39
+ * @returns {Object} Object containing all flags with any overrides applied
40
+ */
41
+ function getFlagsWithOverrides() {
42
+ const result = {};
43
+
44
+ // Add all flags first
45
+ for (const key in flags) {
46
+ const flag = get(key);
47
+ if (flag) {
48
+ result[key] = flag;
49
+ }
50
+ }
51
+
52
+ // Override with any flagOverrides (they take precedence)
53
+ if (flagOverrides) {
54
+ for (const key in flagOverrides) {
55
+ const override = get(key);
56
+ if (override) {
57
+ result[key] = override;
58
+ }
59
+ }
60
+ }
61
+
62
+ return result;
63
+ }
64
+
65
+ /**
66
+ * Replaces all flags with new flag data
67
+ * @param {Object} newFlags - Object containing the new flag data
68
+ */
69
+ function setFlags(newFlags) {
70
+ flags = { ...newFlags };
71
+ }
72
+
73
+ /**
74
+ * Sets an override value for a specific flag
75
+ * @param {string} key The flag key to override
76
+ * @param {*} value The override value for the flag
77
+ */
78
+ function setOverride(key, value) {
79
+ if (!flagOverrides) {
80
+ flagOverrides = {};
81
+ }
82
+ flagOverrides[key] = { value };
83
+ }
84
+
85
+ /**
86
+ * Removes an override for a specific flag
87
+ * @param {string} key The flag key to remove the override for
88
+ */
89
+ function removeOverride(key) {
90
+ if (!flagOverrides || !flagOverrides[key]) {
91
+ return; // No override to remove
92
+ }
93
+
94
+ delete flagOverrides[key];
95
+
96
+ // If no more overrides, reset to undefined for performance
97
+ if (Object.keys(flagOverrides).length === 0) {
98
+ flagOverrides = undefined;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Clears all flag overrides and returns the cleared overrides
104
+ * @returns {Object} The overrides that were cleared, useful for tracking what was removed
105
+ */
106
+ function clearAllOverrides() {
107
+ if (!flagOverrides) {
108
+ return {}; // No overrides to clear, return empty object for consistency
109
+ }
110
+
111
+ const clearedOverrides = { ...flagOverrides };
112
+ flagOverrides = undefined; // Reset to undefined
113
+ return clearedOverrides;
114
+ }
115
+
116
+ /**
117
+ * Gets the internal flag state without overrides applied
118
+ * @returns {Object} The internal flag data structure
119
+ */
120
+ function getFlags() {
121
+ return flags;
122
+ }
123
+
124
+ /**
125
+ * Gets the flag overrides data
126
+ * @returns {Object} The flag overrides object, or empty object if no overrides exist
127
+ */
128
+ function getFlagOverrides() {
129
+ return flagOverrides || {};
130
+ }
131
+
132
+ return {
133
+ clearAllOverrides,
134
+ get,
135
+ getFlagOverrides,
136
+ getFlags,
137
+ getFlagsWithOverrides,
138
+ removeOverride,
139
+ setFlags,
140
+ setOverride,
141
+ };
142
+ }
143
+
144
+ module.exports = FlagStore;
@@ -0,0 +1,342 @@
1
+ const { initialize } = require('../index');
2
+ const stubPlatform = require('./stubPlatform');
3
+ const { respondJson } = require('./mockHttp');
4
+ const { makeBootstrap } = require('./testUtils');
5
+
6
+ // Mock the logger functions
7
+ const mockLogger = () => ({
8
+ error: jest.fn(),
9
+ warn: jest.fn(),
10
+ info: jest.fn(),
11
+ debug: jest.fn(),
12
+ });
13
+
14
+ // Define a basic Plugin structure for tests
15
+ const createTestPlugin = (name = 'Test Plugin') => {
16
+ const plugin = {
17
+ getMetadata: jest.fn().mockReturnValue({ name }),
18
+ register: jest.fn(),
19
+ getHooks: jest.fn().mockReturnValue([]),
20
+ registerDebug: jest.fn(),
21
+ };
22
+
23
+ return plugin;
24
+ };
25
+
26
+ // Helper to initialize the client for tests
27
+ async function withClient(initialContext, configOverrides = {}, plugins = [], testFn) {
28
+ const platform = stubPlatform.defaults();
29
+ const server = platform.testing.http.newServer();
30
+ const logger = mockLogger();
31
+
32
+ // Disable streaming and event sending unless overridden
33
+ const defaults = {
34
+ baseUrl: server.url,
35
+ streaming: false,
36
+ sendEvents: false,
37
+ useLdd: false,
38
+ logger: logger,
39
+ plugins: plugins,
40
+ };
41
+ const config = { ...defaults, ...configOverrides };
42
+ const { client, start } = initialize('env', initialContext, config, platform);
43
+
44
+ server.byDefault(respondJson({}));
45
+ start();
46
+
47
+ try {
48
+ await client.waitForInitialization(10);
49
+ await testFn(client, logger, platform);
50
+ } finally {
51
+ await client.close();
52
+ server.close();
53
+ }
54
+ }
55
+
56
+ describe('LDDebugOverride', () => {
57
+ describe('setOverride method', () => {
58
+ it('should set override value returned by variation method', async () => {
59
+ let debugOverrideInterface;
60
+ const mockPlugin = createTestPlugin('test-plugin');
61
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
62
+ debugOverrideInterface = debugOverride;
63
+ });
64
+
65
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
66
+ expect(client.variation('test-flag', 'default')).toBe('default');
67
+
68
+ debugOverrideInterface.setOverride('test-flag', 'override-value');
69
+ expect(client.variation('test-flag', 'default')).toBe('override-value');
70
+ });
71
+ });
72
+
73
+ it('should override values taking precedence over real flag values from bootstrap', async () => {
74
+ let debugOverrideInterface;
75
+ const mockPlugin = createTestPlugin('test-plugin');
76
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
77
+ debugOverrideInterface = debugOverride;
78
+ });
79
+
80
+ const flags = makeBootstrap({ 'existing-flag': { value: 'real-value', version: 1 } });
81
+
82
+ await withClient({ key: 'user-key', kind: 'user' }, { bootstrap: flags }, [mockPlugin], async client => {
83
+ expect(client.variation('existing-flag', 'default')).toBe('real-value');
84
+
85
+ debugOverrideInterface.setOverride('existing-flag', 'override-value');
86
+ expect(client.variation('existing-flag', 'default')).toBe('override-value');
87
+
88
+ debugOverrideInterface.removeOverride('existing-flag');
89
+ expect(client.variation('existing-flag', 'default')).toBe('real-value');
90
+ });
91
+ });
92
+ });
93
+
94
+ describe('removeOverride method', () => {
95
+ it('should remove individual override and revert to default', async () => {
96
+ let debugOverrideInterface;
97
+ const mockPlugin = createTestPlugin('test-plugin');
98
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
99
+ debugOverrideInterface = debugOverride;
100
+ });
101
+
102
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
103
+ debugOverrideInterface.setOverride('test-flag', 'override-value');
104
+ expect(client.variation('test-flag', 'default')).toBe('override-value');
105
+
106
+ debugOverrideInterface.removeOverride('test-flag');
107
+ expect(client.variation('test-flag', 'default')).toBe('default');
108
+ });
109
+ });
110
+
111
+ it('should remove only the specified override leaving others intact', async () => {
112
+ let debugOverrideInterface;
113
+ const mockPlugin = createTestPlugin('test-plugin');
114
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
115
+ debugOverrideInterface = debugOverride;
116
+ });
117
+
118
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
119
+ debugOverrideInterface.setOverride('flag1', 'value1');
120
+ debugOverrideInterface.setOverride('flag2', 'value2');
121
+ debugOverrideInterface.setOverride('flag3', 'value3');
122
+
123
+ debugOverrideInterface.removeOverride('flag2');
124
+
125
+ expect(client.variation('flag1', 'default')).toBe('value1');
126
+ expect(client.variation('flag2', 'default')).toBe('default');
127
+ expect(client.variation('flag3', 'default')).toBe('value3');
128
+
129
+ const allOverrides = debugOverrideInterface.getAllOverrides();
130
+ expect(allOverrides).toEqual({
131
+ flag1: 'value1',
132
+ flag3: 'value3',
133
+ });
134
+ });
135
+ });
136
+
137
+ it('should handle removing non-existent override without throwing error', async () => {
138
+ let debugOverrideInterface;
139
+ const mockPlugin = createTestPlugin('test-plugin');
140
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
141
+ debugOverrideInterface = debugOverride;
142
+ });
143
+
144
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
145
+ debugOverrideInterface.setOverride('existing-flag', 'value');
146
+
147
+ // Should not throw error
148
+ expect(() => {
149
+ debugOverrideInterface.removeOverride('non-existent-flag');
150
+ }).not.toThrow();
151
+
152
+ // Existing override should remain
153
+ expect(client.variation('existing-flag', 'default')).toBe('value');
154
+ const allOverrides = debugOverrideInterface.getAllOverrides();
155
+ expect(allOverrides).toEqual({ 'existing-flag': 'value' });
156
+ });
157
+ });
158
+
159
+ it('should be callable multiple times on same flag key safely', async () => {
160
+ let debugOverrideInterface;
161
+ const mockPlugin = createTestPlugin('test-plugin');
162
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
163
+ debugOverrideInterface = debugOverride;
164
+ });
165
+
166
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
167
+ debugOverrideInterface.setOverride('test-flag', 'value');
168
+
169
+ debugOverrideInterface.removeOverride('test-flag');
170
+ expect(client.variation('test-flag', 'default')).toBe('default');
171
+
172
+ // Removing again should not cause issues
173
+ expect(() => {
174
+ debugOverrideInterface.removeOverride('test-flag');
175
+ }).not.toThrow();
176
+
177
+ expect(debugOverrideInterface.getAllOverrides()).toEqual({});
178
+ });
179
+ });
180
+ });
181
+
182
+ describe('clearAllOverrides method', () => {
183
+ it('should clear all overrides and revert all flags to their default values', async () => {
184
+ let debugOverrideInterface;
185
+ const mockPlugin = createTestPlugin('test-plugin');
186
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
187
+ debugOverrideInterface = debugOverride;
188
+ });
189
+
190
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
191
+ debugOverrideInterface.setOverride('flag1', 'value1');
192
+ debugOverrideInterface.setOverride('flag2', 'value2');
193
+
194
+ debugOverrideInterface.clearAllOverrides();
195
+ expect(client.variation('flag1', 'default')).toBe('default');
196
+ expect(client.variation('flag2', 'default')).toBe('default');
197
+ expect(debugOverrideInterface.getAllOverrides()).toEqual({});
198
+ });
199
+ });
200
+
201
+ it('should operate safely when no overrides exist', async () => {
202
+ let debugOverrideInterface;
203
+ const mockPlugin = createTestPlugin('test-plugin');
204
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
205
+ debugOverrideInterface = debugOverride;
206
+ });
207
+
208
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => {
209
+ // Should not throw error when no overrides exist
210
+ expect(() => {
211
+ debugOverrideInterface.clearAllOverrides();
212
+ }).not.toThrow();
213
+
214
+ expect(debugOverrideInterface.getAllOverrides()).toEqual({});
215
+ });
216
+ });
217
+ });
218
+
219
+ describe('getAllOverrides method', () => {
220
+ it('should return all current overrides', async () => {
221
+ let debugOverrideInterface;
222
+ const mockPlugin = createTestPlugin('test-plugin');
223
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
224
+ debugOverrideInterface = debugOverride;
225
+ });
226
+
227
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => {
228
+ debugOverrideInterface.setOverride('test-flag', 'override-value');
229
+
230
+ const allOverrides = debugOverrideInterface.getAllOverrides();
231
+ expect(allOverrides).toEqual({ 'test-flag': 'override-value' });
232
+ });
233
+ });
234
+
235
+ it('should return empty object when no overrides have been set', async () => {
236
+ let debugOverrideInterface;
237
+ const mockPlugin = createTestPlugin('test-plugin');
238
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
239
+ debugOverrideInterface = debugOverride;
240
+ });
241
+
242
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => {
243
+ const allOverrides = debugOverrideInterface.getAllOverrides();
244
+ expect(allOverrides).toEqual({});
245
+ expect(typeof allOverrides).toBe('object');
246
+ expect(Array.isArray(allOverrides)).toBe(false);
247
+ });
248
+ });
249
+
250
+ it('should return immutable copy not reference to internal state', async () => {
251
+ let debugOverrideInterface;
252
+ const mockPlugin = createTestPlugin('test-plugin');
253
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
254
+ debugOverrideInterface = debugOverride;
255
+ });
256
+
257
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
258
+ debugOverrideInterface.setOverride('test-flag', 'original-value');
259
+
260
+ const overrides1 = debugOverrideInterface.getAllOverrides();
261
+ const overrides2 = debugOverrideInterface.getAllOverrides();
262
+
263
+ // Should be different objects
264
+ expect(overrides1).not.toBe(overrides2);
265
+
266
+ // Modifying returned object should not affect internal state
267
+ overrides1['new-flag'] = 'new-value';
268
+ delete overrides1['test-flag'];
269
+
270
+ expect(client.variation('test-flag', 'default')).toBe('original-value');
271
+ expect(client.variation('new-flag', 'default')).toBe('default');
272
+
273
+ const overrides3 = debugOverrideInterface.getAllOverrides();
274
+ expect(overrides3).toEqual({ 'test-flag': 'original-value' });
275
+ });
276
+ });
277
+
278
+ it('should maintain consistency across different operations', async () => {
279
+ let debugOverrideInterface;
280
+ const mockPlugin = createTestPlugin('test-plugin');
281
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
282
+ debugOverrideInterface = debugOverride;
283
+ });
284
+
285
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => {
286
+ // Test consistency through various operations
287
+ expect(debugOverrideInterface.getAllOverrides()).toEqual({});
288
+
289
+ debugOverrideInterface.setOverride('flag1', 'value1');
290
+ expect(debugOverrideInterface.getAllOverrides()).toEqual({ flag1: 'value1' });
291
+
292
+ debugOverrideInterface.setOverride('flag2', 'value2');
293
+ expect(debugOverrideInterface.getAllOverrides()).toEqual({ flag1: 'value1', flag2: 'value2' });
294
+
295
+ debugOverrideInterface.removeOverride('flag1');
296
+ expect(debugOverrideInterface.getAllOverrides()).toEqual({ flag2: 'value2' });
297
+
298
+ debugOverrideInterface.setOverride('flag2', 'updated-value2');
299
+ expect(debugOverrideInterface.getAllOverrides()).toEqual({ flag2: 'updated-value2' });
300
+
301
+ debugOverrideInterface.clearAllOverrides();
302
+ expect(debugOverrideInterface.getAllOverrides()).toEqual({});
303
+ });
304
+ });
305
+ });
306
+
307
+ describe('integration with client methods', () => {
308
+ it('should work correctly with variationDetail method', async () => {
309
+ let debugOverrideInterface;
310
+ const mockPlugin = createTestPlugin('test-plugin');
311
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
312
+ debugOverrideInterface = debugOverride;
313
+ });
314
+
315
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
316
+ debugOverrideInterface.setOverride('test-flag', 'override-value');
317
+
318
+ const detail = client.variationDetail('test-flag', 'default');
319
+ expect(detail.value).toBe('override-value');
320
+ });
321
+ });
322
+
323
+ it('should include overrides in allFlags method output', async () => {
324
+ let debugOverrideInterface;
325
+ const mockPlugin = createTestPlugin('test-plugin');
326
+ mockPlugin.registerDebug.mockImplementation(debugOverride => {
327
+ debugOverrideInterface = debugOverride;
328
+ });
329
+
330
+ const flags = makeBootstrap({ 'real-flag': { value: 'real-value', version: 1 } });
331
+
332
+ await withClient({ key: 'user-key', kind: 'user' }, { bootstrap: flags }, [mockPlugin], async client => {
333
+ debugOverrideInterface.setOverride('override-flag', 'override-value');
334
+ debugOverrideInterface.setOverride('real-flag', 'overridden-real-value');
335
+
336
+ const allFlags = client.allFlags();
337
+ expect(allFlags['real-flag']).toBe('overridden-real-value');
338
+ expect(allFlags['override-flag']).toBe('override-value');
339
+ });
340
+ });
341
+ });
342
+ });
@@ -21,11 +21,19 @@ const createTestHook = (name = 'Test Hook') => ({
21
21
  });
22
22
 
23
23
  // Define a basic Plugin structure for tests
24
- const createTestPlugin = (name = 'Test Plugin', hooks = []) => ({
25
- getMetadata: jest.fn().mockReturnValue({ name }),
26
- register: jest.fn(),
27
- getHooks: jest.fn().mockReturnValue(hooks),
28
- });
24
+ const createTestPlugin = (name = 'Test Plugin', hooks = [], includeDebug = false) => {
25
+ const plugin = {
26
+ getMetadata: jest.fn().mockReturnValue({ name }),
27
+ register: jest.fn(),
28
+ getHooks: jest.fn().mockReturnValue(hooks),
29
+ };
30
+
31
+ if (includeDebug) {
32
+ plugin.registerDebug = jest.fn();
33
+ }
34
+
35
+ return plugin;
36
+ };
29
37
 
30
38
  // Helper to initialize the client for tests
31
39
  async function withClient(initialContext, configOverrides = {}, plugins = [], testFn) {
@@ -212,3 +220,53 @@ it('passes correct environmentMetadata without optional fields', async () => {
212
220
  }
213
221
  );
214
222
  });
223
+
224
+ it('registers plugins and calls registerDebug when a plugin implements it', async () => {
225
+ const mockPlugin = createTestPlugin('test-plugin', [], true);
226
+
227
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => {
228
+ expect(mockPlugin.register).toHaveBeenCalled();
229
+
230
+ // Verify that registerDebug was called
231
+ expect(mockPlugin.registerDebug).toHaveBeenCalledTimes(1);
232
+ });
233
+ });
234
+
235
+ it('registers plugins but does not call registerDebug when a plugin does not implement it', async () => {
236
+ const mockPlugin = createTestPlugin('test-plugin', [], false);
237
+
238
+ await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => {
239
+ expect(mockPlugin.register).toHaveBeenCalled();
240
+
241
+ // Verify that registerDebug was not called
242
+ expect(mockPlugin.registerDebug).toBeUndefined();
243
+ });
244
+ });
245
+
246
+ it('registers multiple plugins and calls registerDebug selectively', async () => {
247
+ const mockPluginWithDebug1 = createTestPlugin('test-plugin-with-debug-1', [], true);
248
+ const mockPluginWithDebug2 = createTestPlugin('test-plugin-with-debug-2', [], true);
249
+ const mockPluginWithoutDebug1 = createTestPlugin('test-plugin-without-debug-1', [], false);
250
+ const mockPluginWithoutDebug2 = createTestPlugin('test-plugin-without-debug-2', [], false);
251
+
252
+ await withClient(
253
+ { key: 'user-key', kind: 'user' },
254
+ {},
255
+ [mockPluginWithDebug1, mockPluginWithoutDebug1, mockPluginWithDebug2, mockPluginWithoutDebug2],
256
+ async () => {
257
+ // Verify all plugins were registered
258
+ expect(mockPluginWithDebug1.register).toHaveBeenCalled();
259
+ expect(mockPluginWithDebug2.register).toHaveBeenCalled();
260
+ expect(mockPluginWithoutDebug1.register).toHaveBeenCalled();
261
+ expect(mockPluginWithoutDebug2.register).toHaveBeenCalled();
262
+
263
+ // Verify that registerDebug was called only on plugins that implement it
264
+ expect(mockPluginWithDebug1.registerDebug).toHaveBeenCalledTimes(1);
265
+ expect(mockPluginWithDebug2.registerDebug).toHaveBeenCalledTimes(1);
266
+
267
+ // Verify that registerDebug was not called on plugins that don't implement it
268
+ expect(mockPluginWithoutDebug1.registerDebug).toBeUndefined();
269
+ expect(mockPluginWithoutDebug2.registerDebug).toBeUndefined();
270
+ }
271
+ );
272
+ });
package/src/index.js CHANGED
@@ -18,7 +18,13 @@ const { checkContext, getContextKeys } = require('./context');
18
18
  const { InspectorTypes, InspectorManager } = require('./InspectorManager');
19
19
  const timedPromise = require('./timedPromise');
20
20
  const createHookRunner = require('./HookRunner');
21
- const { getPluginHooks, registerPlugins, createPluginEnvironment } = require('./plugins');
21
+ const FlagStore = require('./FlagStore');
22
+ const {
23
+ getPluginHooks,
24
+ registerPlugins,
25
+ registerPluginsForDebugOverride,
26
+ createPluginEnvironment,
27
+ } = require('./plugins');
22
28
  const changeEvent = 'change';
23
29
  const internalChangeEvent = 'internal-change';
24
30
  const highTimeoutThreshold = 5;
@@ -76,7 +82,8 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
76
82
 
77
83
  const requestor = Requestor(platform, options, environment);
78
84
 
79
- let flags = {};
85
+ const flagStore = FlagStore();
86
+
80
87
  let useLocalStorage;
81
88
  let streamActive;
82
89
  let streamForcedState = options.streaming;
@@ -188,7 +195,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
188
195
  function notifyInspectionFlagsChanged() {
189
196
  if (inspectorManager.hasListeners(InspectorTypes.flagDetailsChanged)) {
190
197
  inspectorManager.onFlags(
191
- Object.entries(flags)
198
+ Object.entries(flagStore.getFlagsWithOverrides())
192
199
  .map(([key, value]) => ({ key, detail: getFlagDetail(value) }))
193
200
  .reduce((acc, cur) => {
194
201
  // eslint-disable-next-line no-param-reassign
@@ -232,7 +239,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
232
239
  default: defaultValue,
233
240
  creationDate: now.getTime(),
234
241
  };
235
- const flag = flags[key];
242
+ const flag = flagStore.getFlags()[key];
236
243
  if (flag) {
237
244
  event.version = flag.flagVersion ? flag.flagVersion : flag.version;
238
245
  event.trackEvents = flag.trackEvents;
@@ -262,7 +269,10 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
262
269
  if (stateProvider) {
263
270
  // We're being controlled by another client instance, so only that instance is allowed to change the context
264
271
  logger.warn(messages.identifyDisabled());
265
- return utils.wrapPromiseCallback(Promise.resolve(utils.transformVersionedValuesToValues(flags)), onDone);
272
+ return utils.wrapPromiseCallback(
273
+ Promise.resolve(utils.transformVersionedValuesToValues(flagStore.getFlagsWithOverrides())),
274
+ onDone
275
+ );
266
276
  }
267
277
  let afterIdentify;
268
278
  const clearFirst = useLocalStorage && persistentFlagStore ? persistentFlagStore.clearFlags() : Promise.resolve();
@@ -328,10 +338,10 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
328
338
 
329
339
  function variationDetailInternal(key, defaultValue, sendEvent, includeReasonInEvent, isAllFlags, notifyInspection) {
330
340
  let detail;
331
- let flag;
332
341
 
333
- if (flags && utils.objectHasOwnProperty(flags, key) && flags[key] && !flags[key].deleted) {
334
- flag = flags[key];
342
+ const flag = flagStore.get(key);
343
+
344
+ if (flag) {
335
345
  detail = getFlagDetail(flag);
336
346
  if (flag.value === null || flag.value === undefined) {
337
347
  detail.value = defaultValue;
@@ -373,21 +383,14 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
373
383
  function allFlags() {
374
384
  const results = {};
375
385
 
376
- if (!flags) {
386
+ const allFlags = flagStore.getFlagsWithOverrides();
387
+
388
+ if (!allFlags) {
377
389
  return results;
378
390
  }
379
391
 
380
- for (const key in flags) {
381
- if (utils.objectHasOwnProperty(flags, key) && !flags[key].deleted) {
382
- results[key] = variationDetailInternal(
383
- key,
384
- null,
385
- !options.sendEventsOnlyForVariation,
386
- false,
387
- true,
388
- false
389
- ).value;
390
- }
392
+ for (const key in allFlags) {
393
+ results[key] = variationDetailInternal(key, null, !options.sendEventsOnlyForVariation, false, true, false).value;
391
394
  }
392
395
 
393
396
  return results;
@@ -485,6 +488,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
485
488
  // If both the flag and the patch have a version property, then the patch version must be
486
489
  // greater than the flag version for us to accept the patch. If either one has no version
487
490
  // then the patch always succeeds.
491
+ const flags = flagStore.getFlags();
488
492
  const oldFlag = flags[data.key];
489
493
  if (!oldFlag || !oldFlag.version || !data.version || oldFlag.version < data.version) {
490
494
  logger.debug(messages.debugStreamPatch(data.key));
@@ -492,6 +496,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
492
496
  const newFlag = utils.extend({}, data);
493
497
  delete newFlag['key'];
494
498
  flags[data.key] = newFlag;
499
+ flagStore.setFlags(flags);
495
500
  const newDetail = getFlagDetail(newFlag);
496
501
  if (oldFlag) {
497
502
  mods[data.key] = { previous: oldFlag.value, current: newDetail };
@@ -509,6 +514,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
509
514
  if (!data) {
510
515
  return;
511
516
  }
517
+ const flags = flagStore.getFlags();
512
518
  if (!flags[data.key] || flags[data.key].version < data.version) {
513
519
  logger.debug(messages.debugStreamDelete(data.key));
514
520
  const mods = {};
@@ -516,6 +522,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
516
522
  mods[data.key] = { previous: flags[data.key].value };
517
523
  }
518
524
  flags[data.key] = { version: data.version, deleted: true };
525
+ flagStore.setFlags(flags);
519
526
  notifyInspectionFlagChanged(data, flags[data.key]);
520
527
  handleFlagChanges(mods); // don't wait for this Promise to be resolved
521
528
  } else {
@@ -542,6 +549,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
542
549
  return Promise.resolve();
543
550
  }
544
551
 
552
+ const flags = flagStore.getFlags();
545
553
  for (const key in flags) {
546
554
  if (utils.objectHasOwnProperty(flags, key) && flags[key]) {
547
555
  if (newFlags[key] && !utils.deepEquals(newFlags[key].value, flags[key].value)) {
@@ -557,7 +565,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
557
565
  }
558
566
  }
559
567
 
560
- flags = { ...newFlags };
568
+ flagStore.setFlags({ ...newFlags });
561
569
 
562
570
  notifyInspectionFlagsChanged();
563
571
 
@@ -580,7 +588,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
580
588
  });
581
589
 
582
590
  emitter.emit(changeEvent, changeEventParams);
583
- emitter.emit(internalChangeEvent, flags);
591
+ emitter.emit(internalChangeEvent, flagStore.getFlagsWithOverrides());
584
592
 
585
593
  // By default, we send feature evaluation events whenever we have received new flag values -
586
594
  // the client has in effect evaluated these flags just by receiving them. This can be suppressed
@@ -595,7 +603,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
595
603
  }
596
604
 
597
605
  if (useLocalStorage && persistentFlagStore) {
598
- return persistentFlagStore.saveFlags(flags);
606
+ return persistentFlagStore.saveFlags(flagStore.getFlags());
599
607
  } else {
600
608
  return Promise.resolve();
601
609
  }
@@ -666,7 +674,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
666
674
  if (typeof options.bootstrap === 'object') {
667
675
  // Set the flags as soon as possible before we get into any async code, so application code can read
668
676
  // them even if the ready event has not yet fired.
669
- flags = readFlagsFromBootstrap(options.bootstrap);
677
+ flagStore.setFlags(readFlagsFromBootstrap(options.bootstrap));
670
678
  }
671
679
 
672
680
  if (stateProvider) {
@@ -718,7 +726,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
718
726
  function finishInitWithLocalStorage() {
719
727
  return persistentFlagStore.loadFlags().then(storedFlags => {
720
728
  if (storedFlags === null || storedFlags === undefined) {
721
- flags = {};
729
+ flagStore.setFlags({});
722
730
  return requestor
723
731
  .fetchFlagSettings(ident.getContext(), hash)
724
732
  .then(requestedFlags => replaceAllFlags(requestedFlags || {}))
@@ -731,7 +739,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
731
739
  // We're reading the flags from local storage. Signal that we're ready,
732
740
  // then update localStorage for the next page load. We won't signal changes or update
733
741
  // the in-memory flags unless you subscribe for changes
734
- flags = storedFlags;
742
+ flagStore.setFlags(storedFlags);
735
743
  utils.onNextTick(signalSuccessfulInit);
736
744
 
737
745
  return requestor
@@ -746,14 +754,14 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
746
754
  return requestor
747
755
  .fetchFlagSettings(ident.getContext(), hash)
748
756
  .then(requestedFlags => {
749
- flags = requestedFlags || {};
757
+ flagStore.setFlags(requestedFlags || {});
750
758
 
751
759
  notifyInspectionFlagsChanged();
752
760
  // Note, we don't need to call updateSettings here because local storage and change events are not relevant
753
761
  signalSuccessfulInit();
754
762
  })
755
763
  .catch(err => {
756
- flags = {};
764
+ flagStore.setFlags({});
757
765
  signalFailedInit(err);
758
766
  });
759
767
  }
@@ -761,7 +769,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
761
769
  function initFromStateProvider(state) {
762
770
  environment = state.environment;
763
771
  ident.setContext(state.context);
764
- flags = { ...state.flags };
772
+ flagStore.setFlags({ ...state.flags });
765
773
  utils.onNextTick(signalSuccessfulInit);
766
774
  }
767
775
 
@@ -800,7 +808,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
800
808
  }
801
809
  const finishClose = () => {
802
810
  closed = true;
803
- flags = {};
811
+ flagStore.setFlags({});
804
812
  };
805
813
  const p = Promise.resolve()
806
814
  .then(() => {
@@ -820,7 +828,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
820
828
 
821
829
  function getFlagsInternal() {
822
830
  // used by Electron integration
823
- return flags;
831
+ return flagStore.getFlagsWithOverrides();
824
832
  }
825
833
 
826
834
  function waitForInitializationWithTimeout(timeout) {
@@ -880,6 +888,94 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
880
888
 
881
889
  registerPlugins(logger, pluginEnvironment, client, plugins);
882
890
 
891
+ function setOverride(key, value) {
892
+ const mods = {};
893
+
894
+ const currentFlag = flagStore.get(key);
895
+ const currentValue = currentFlag ? currentFlag.value : null;
896
+
897
+ if (currentValue === value) {
898
+ logger.debug(`setOverride: No change needed for ${key}, value already ${value}`);
899
+ return;
900
+ }
901
+
902
+ flagStore.setOverride(key, value);
903
+ const newFlag = flagStore.get(key);
904
+ const newDetail = getFlagDetail(newFlag);
905
+
906
+ mods[key] = { previous: currentValue, current: newDetail };
907
+
908
+ notifyInspectionFlagChanged({ key }, newFlag);
909
+ handleFlagChanges(mods);
910
+ }
911
+
912
+ function removeOverride(key) {
913
+ const flagOverrides = flagStore.getFlagOverrides();
914
+ if (!flagOverrides[key]) {
915
+ return; // No override to remove
916
+ }
917
+
918
+ const mods = {};
919
+ const oldOverride = flagOverrides[key];
920
+ const flags = flagStore.getFlags();
921
+ const realFlag = flags[key];
922
+
923
+ mods[key] = { previous: oldOverride.value, current: realFlag ? getFlagDetail(realFlag) : undefined };
924
+
925
+ flagStore.removeOverride(key);
926
+ notifyInspectionFlagChanged({ key }, realFlag);
927
+ handleFlagChanges(mods); // don't wait for this Promise to be resolved
928
+ }
929
+
930
+ function clearAllOverrides() {
931
+ const flagOverrides = flagStore.getFlagOverrides();
932
+ if (Object.keys(flagOverrides).length === 0) {
933
+ return; // No overrides to clear
934
+ }
935
+
936
+ const mods = {};
937
+ const flags = flagStore.getFlags();
938
+ Object.keys(flagOverrides).forEach(key => {
939
+ const oldOverride = flagOverrides[key];
940
+ const realFlag = flags[key];
941
+
942
+ mods[key] = { previous: oldOverride.value, current: realFlag ? getFlagDetail(realFlag) : undefined };
943
+ });
944
+
945
+ flagStore.clearAllOverrides();
946
+
947
+ if (Object.keys(mods).length > 0) {
948
+ handleFlagChanges(mods); // don't wait for this Promise to be resolved
949
+ }
950
+ }
951
+
952
+ function getAllOverrides() {
953
+ const flagOverrides = flagStore.getFlagOverrides();
954
+
955
+ if (!flagOverrides) {
956
+ return {}; // No overrides set
957
+ }
958
+
959
+ const result = {};
960
+ Object.keys(flagOverrides).forEach(key => {
961
+ const override = flagOverrides[key];
962
+ if (override) {
963
+ result[key] = override.value;
964
+ }
965
+ });
966
+ return result;
967
+ }
968
+
969
+ const debugOverride = {
970
+ setOverride: setOverride,
971
+ removeOverride: removeOverride,
972
+ clearAllOverrides: clearAllOverrides,
973
+ getAllOverrides: getAllOverrides,
974
+ };
975
+
976
+ // Register plugins for debug override capabilities
977
+ registerPluginsForDebugOverride(logger, debugOverride, plugins);
978
+
883
979
  return {
884
980
  client: client, // The client object containing all public methods.
885
981
  options: options, // The validated configuration object, including all defaults.
package/src/plugins.js CHANGED
@@ -56,6 +56,22 @@ function registerPlugins(logger, environmentMetadata, client, plugins) {
56
56
  });
57
57
  }
58
58
 
59
+ /**
60
+ * Registers plugins for debug override capabilities
61
+ * @param {{ error: (message: string) => void }} logger - The logger instance
62
+ * @param {Object} debugOverride - The debug override interface object
63
+ * @param {Array<{registerDebug?: (debugOverride: object) => void}>} plugins - Array of plugin objects that may implement registerDebug
64
+ */
65
+ function registerPluginsForDebugOverride(logger, debugOverride, plugins) {
66
+ plugins.forEach(plugin => {
67
+ try {
68
+ plugin.registerDebug?.(debugOverride);
69
+ } catch (error) {
70
+ logger.error(`Exception thrown registering debug override with plugin ${getPluginName(logger, plugin)}.`);
71
+ }
72
+ });
73
+ }
74
+
59
75
  /**
60
76
  * Creates a plugin environment object
61
77
  * @param {{userAgent: string, version: string}} platform - The platform object
@@ -105,5 +121,6 @@ function createPluginEnvironment(platform, env, options) {
105
121
  module.exports = {
106
122
  getPluginHooks,
107
123
  registerPlugins,
124
+ registerPluginsForDebugOverride,
108
125
  createPluginEnvironment,
109
126
  };
package/test-types.ts CHANGED
@@ -55,7 +55,7 @@ const plugin: ld.LDPlugin = {
55
55
  }),
56
56
  register(client: ld.LDClientBase, environmentMetadata: ld.LDPluginEnvironmentMetadata): void {
57
57
  },
58
-
58
+ registerDebug(debugOverride: ld.LDDebugOverride): void {},
59
59
  getHooks(metadata: ld.LDPluginEnvironmentMetadata): ld.Hook[] {
60
60
  return [];
61
61
  },
package/typings.d.ts CHANGED
@@ -74,7 +74,7 @@ declare module 'launchdarkly-js-sdk-common' {
74
74
  }
75
75
 
76
76
  /**
77
- * Meta-data about a hook implementation.
77
+ * Metadata about a hook implementation.
78
78
  */
79
79
  export interface HookMetadata {
80
80
  /**
@@ -149,7 +149,6 @@ declare module 'launchdarkly-js-sdk-common' {
149
149
  readonly metricValue?: number;
150
150
  }
151
151
 
152
-
153
152
  /**
154
153
  * Interface for extending SDK functionality via hooks.
155
154
  */
@@ -251,7 +250,7 @@ declare module 'launchdarkly-js-sdk-common' {
251
250
  }
252
251
 
253
252
  /**
254
- * Meta-data about a plugin implementation.
253
+ * Metadata about a plugin implementation.
255
254
  *
256
255
  * May be used in logs and analytics to identify the plugin.
257
256
  */
@@ -363,6 +362,59 @@ export interface LDPlugin {
363
362
  * @param metadata
364
363
  */
365
364
  getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[];
365
+
366
+ /**
367
+ * An optional function called if the plugin wants to register debug capabilities.
368
+ * This method allows plugins to receive a debug override interface for
369
+ * temporarily overriding flag values during development and testing.
370
+ *
371
+ * @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time.
372
+ * The API may change in future versions.
373
+ *
374
+ * @param debugOverride The debug override interface instance
375
+ */
376
+ registerDebug?(debugOverride: LDDebugOverride): void;
377
+ }
378
+
379
+ /**
380
+ * Debug interface for plugins that need to override flag values during development.
381
+ * This interface provides methods to temporarily override flag values that take
382
+ * precedence over the actual flag values from LaunchDarkly. These overrides are
383
+ * useful for testing, development, and debugging scenarios.
384
+ *
385
+ * @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time.
386
+ * The API may change in future versions.
387
+ */
388
+ export interface LDDebugOverride {
389
+ /**
390
+ * Set an override value for a flag that takes precedence over the real flag value.
391
+ *
392
+ * @param flagKey The flag key.
393
+ * @param value The override value.
394
+ */
395
+ setOverride(flagKey: string, value: LDFlagValue): void;
396
+
397
+ /**
398
+ * Remove an override value for a flag, reverting to the real flag value.
399
+ *
400
+ * @param flagKey The flag key.
401
+ */
402
+ removeOverride(flagKey: string): void;
403
+
404
+ /**
405
+ * Clear all override values, reverting all flags to their real values.
406
+ */
407
+ clearAllOverrides(): void;
408
+
409
+ /**
410
+ * Get all currently active flag overrides.
411
+ *
412
+ * @returns
413
+ * An object containing all active overrides as key-value pairs,
414
+ * where keys are flag keys and values are the overridden flag values.
415
+ * Returns an empty object if no overrides are active.
416
+ */
417
+ getAllOverrides(): LDFlagSet;
366
418
  }
367
419
 
368
420
  /**
@@ -1071,6 +1123,13 @@ export interface LDPlugin {
1071
1123
  * Changing the current context also causes all feature flag values to be reloaded. Until that has
1072
1124
  * finished, calls to {@link variation} will still return flag values for the previous context. You can
1073
1125
  * use a callback or a Promise to determine when the new flag values are available.
1126
+ *
1127
+ * It is possible that the identify call will fail. In that case, when using a callback, the callback will receive
1128
+ * an error value. While the SDK will continue to function, the developer will need to be aware that
1129
+ * calls to {@link variation} will still return flag values for the previous context.
1130
+ *
1131
+ * When using a promise, it is important that you handle the rejection case;
1132
+ * otherwise it will become an unhandled Promise rejection, which is a serious error on some platforms.
1074
1133
  *
1075
1134
  * @param context
1076
1135
  * The context properties. Must contain at least the `key` property.