Skip to content

Commit 905b593

Browse files
committed
feat: Add plugin support for node.
1 parent 6a2ce65 commit 905b593

File tree

9 files changed

+426
-19
lines changed

9 files changed

+426
-19
lines changed
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { integrations, LDContext, LDLogger } from '@launchdarkly/js-server-sdk-common';
2+
3+
import { LDOptions } from '../src/api/LDOptions';
4+
import { LDPlugin } from '../src/api/LDPlugin';
5+
import LDClientNode from '../src/LDClientNode';
6+
import NodeInfo from '../src/platform/NodeInfo';
7+
8+
// Test for plugin registration
9+
it('registers plugins and executes hooks during initialization', async () => {
10+
const logger: LDLogger = {
11+
debug: jest.fn(),
12+
info: jest.fn(),
13+
warn: jest.fn(),
14+
error: jest.fn(),
15+
};
16+
17+
const mockHook: integrations.Hook = {
18+
getMetadata(): integrations.HookMetadata {
19+
return {
20+
name: 'test-hook',
21+
};
22+
},
23+
beforeEvaluation: jest.fn(() => ({})),
24+
afterEvaluation: jest.fn(() => ({})),
25+
};
26+
27+
const mockPlugin: LDPlugin = {
28+
getMetadata: () => ({ name: 'test-plugin' }),
29+
register: jest.fn(),
30+
getHooks: () => [mockHook],
31+
};
32+
33+
const client = new LDClientNode('test', { offline: true, logger, plugins: [mockPlugin] });
34+
35+
// Verify the plugin was registered
36+
expect(mockPlugin.register).toHaveBeenCalled();
37+
38+
// Now test that hooks work by calling identify and variation
39+
const context: LDContext = { key: 'user-key', kind: 'user' };
40+
41+
await client.variation('flag-key', context, false);
42+
43+
expect(mockHook.beforeEvaluation).toHaveBeenCalledWith(
44+
{
45+
context,
46+
defaultValue: false,
47+
flagKey: 'flag-key',
48+
method: 'LDClient.variation',
49+
environmentId: undefined,
50+
},
51+
{},
52+
);
53+
54+
expect(mockHook.afterEvaluation).toHaveBeenCalled();
55+
});
56+
57+
// Test for multiple plugins with hooks
58+
it('registers multiple plugins and executes all hooks', async () => {
59+
const logger: LDLogger = {
60+
debug: jest.fn(),
61+
info: jest.fn(),
62+
warn: jest.fn(),
63+
error: jest.fn(),
64+
};
65+
66+
const mockHook1: integrations.Hook = {
67+
getMetadata(): integrations.HookMetadata {
68+
return {
69+
name: 'test-hook-1',
70+
};
71+
},
72+
beforeEvaluation: jest.fn(() => ({})),
73+
afterEvaluation: jest.fn(() => ({})),
74+
};
75+
76+
const mockHook2: integrations.Hook = {
77+
getMetadata(): integrations.HookMetadata {
78+
return {
79+
name: 'test-hook-2',
80+
};
81+
},
82+
beforeEvaluation: jest.fn(() => ({})),
83+
afterEvaluation: jest.fn(() => ({})),
84+
};
85+
86+
const mockPlugin1: LDPlugin = {
87+
getMetadata: () => ({ name: 'test-plugin-1' }),
88+
register: jest.fn(),
89+
getHooks: () => [mockHook1],
90+
};
91+
92+
const mockPlugin2: LDPlugin = {
93+
getMetadata: () => ({ name: 'test-plugin-2' }),
94+
register: jest.fn(),
95+
getHooks: () => [mockHook2],
96+
};
97+
98+
const client = new LDClientNode('test', {
99+
offline: true,
100+
logger,
101+
plugins: [mockPlugin1, mockPlugin2],
102+
});
103+
104+
// Verify plugins were registered
105+
expect(mockPlugin1.register).toHaveBeenCalled();
106+
expect(mockPlugin2.register).toHaveBeenCalled();
107+
108+
// Test that both hooks work
109+
const context: LDContext = { key: 'user-key', kind: 'user' };
110+
await client.variation('flag-key', context, false);
111+
112+
expect(mockHook1.beforeEvaluation).toHaveBeenCalled();
113+
expect(mockHook1.afterEvaluation).toHaveBeenCalled();
114+
expect(mockHook2.beforeEvaluation).toHaveBeenCalled();
115+
expect(mockHook2.afterEvaluation).toHaveBeenCalled();
116+
});
117+
118+
it('passes correct environmentMetadata to plugin getHooks and register functions', async () => {
119+
const logger: LDLogger = {
120+
debug: jest.fn(),
121+
info: jest.fn(),
122+
warn: jest.fn(),
123+
error: jest.fn(),
124+
};
125+
126+
const mockHook: integrations.Hook = {
127+
getMetadata(): integrations.HookMetadata {
128+
return {
129+
name: 'test-hook',
130+
};
131+
},
132+
beforeEvaluation: jest.fn(() => ({})),
133+
afterEvaluation: jest.fn(() => ({})),
134+
};
135+
136+
const mockPlugin: LDPlugin = {
137+
getMetadata: () => ({ name: 'test-plugin' }),
138+
register: jest.fn(),
139+
getHooks: jest.fn(() => [mockHook]),
140+
};
141+
142+
const options: LDOptions = {
143+
wrapperName: 'test-wrapper',
144+
wrapperVersion: '2.0.0',
145+
application: {
146+
id: 'test-app',
147+
name: 'TestApp',
148+
version: '3.0.0',
149+
versionName: '3',
150+
},
151+
offline: true,
152+
logger,
153+
plugins: [mockPlugin],
154+
};
155+
156+
// eslint-disable-next-line no-new
157+
new LDClientNode('test', options);
158+
const platformInfo = new NodeInfo(options);
159+
const sdkData = platformInfo.sdkData();
160+
expect(sdkData.name).toBeDefined();
161+
expect(sdkData.version).toBeDefined();
162+
163+
// Verify getHooks was called with correct environmentMetadata
164+
expect(mockPlugin.getHooks).toHaveBeenCalledWith({
165+
sdk: {
166+
name: sdkData.userAgentBase,
167+
version: sdkData.version,
168+
wrapperName: options.wrapperName,
169+
wrapperVersion: options.wrapperVersion,
170+
},
171+
application: {
172+
id: options.application?.id,
173+
name: options.application?.name,
174+
version: options.application?.version,
175+
versionName: options.application?.versionName,
176+
},
177+
sdkKey: 'test',
178+
});
179+
180+
// Verify register was called with correct environmentMetadata
181+
expect(mockPlugin.register).toHaveBeenCalledWith(
182+
expect.any(Object), // client
183+
{
184+
sdk: {
185+
name: sdkData.userAgentBase,
186+
version: sdkData.version,
187+
wrapperName: options.wrapperName,
188+
wrapperVersion: options.wrapperVersion,
189+
},
190+
application: {
191+
id: options.application?.id,
192+
version: options.application?.version,
193+
name: options.application?.name,
194+
versionName: options.application?.versionName,
195+
},
196+
sdkKey: 'test',
197+
},
198+
);
199+
});
200+
201+
it('passes correct environmentMetadata without optional fields', async () => {
202+
const logger: LDLogger = {
203+
debug: jest.fn(),
204+
info: jest.fn(),
205+
warn: jest.fn(),
206+
error: jest.fn(),
207+
};
208+
209+
const mockHook: integrations.Hook = {
210+
getMetadata(): integrations.HookMetadata {
211+
return {
212+
name: 'test-hook',
213+
};
214+
},
215+
beforeEvaluation: jest.fn(() => ({})),
216+
afterEvaluation: jest.fn(() => ({})),
217+
};
218+
219+
const mockPlugin: LDPlugin = {
220+
getMetadata: () => ({ name: 'test-plugin' }),
221+
register: jest.fn(),
222+
getHooks: jest.fn(() => [mockHook]),
223+
};
224+
225+
const options: LDOptions = {
226+
offline: true,
227+
logger,
228+
plugins: [mockPlugin],
229+
};
230+
231+
// eslint-disable-next-line no-new
232+
new LDClientNode('test', options);
233+
234+
const platformInfo = new NodeInfo(options);
235+
const sdkData = platformInfo.sdkData();
236+
expect(sdkData.name).toBeDefined();
237+
expect(sdkData.version).toBeDefined();
238+
239+
// Verify getHooks was called with correct environmentMetadata
240+
expect(mockPlugin.getHooks).toHaveBeenCalledWith({
241+
sdk: {
242+
name: sdkData.userAgentBase,
243+
version: sdkData.version,
244+
},
245+
sdkKey: 'test',
246+
});
247+
248+
// Verify register was called with correct environmentMetadata
249+
expect(mockPlugin.register).toHaveBeenCalledWith(
250+
expect.any(Object), // client
251+
{
252+
sdk: {
253+
name: sdkData.userAgentBase,
254+
version: sdkData.version,
255+
},
256+
sdkKey: 'test',
257+
},
258+
);
259+
});

packages/sdk/server-node/src/LDClientNode.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,53 @@ import { format } from 'util';
44

55
import {
66
BasicLogger,
7+
internal,
78
LDClientImpl,
8-
LDOptions,
9+
LDPluginEnvironmentMetadata,
10+
Platform,
911
SafeLogger,
12+
TypeValidators,
1013
} from '@launchdarkly/js-server-sdk-common';
14+
import { LDClientCallbacks } from '@launchdarkly/js-server-sdk-common/dist/LDClientImpl';
15+
import { ServerInternalOptions } from '@launchdarkly/js-server-sdk-common/dist/options/ServerInternalOptions';
1116

12-
import { BigSegmentStoreStatusProvider } from './api';
17+
import { BigSegmentStoreStatusProvider, LDClient } from './api';
18+
import { LDOptions } from './api/LDOptions';
19+
import { LDPlugin } from './api/LDPlugin';
1320
import BigSegmentStoreStatusProviderNode from './BigSegmentsStoreStatusProviderNode';
1421
import { Emits } from './Emits';
1522
import NodePlatform from './platform/NodePlatform';
1623

17-
class ClientEmitter extends EventEmitter {}
18-
1924
/**
20-
* @ignore
25+
* @internal
26+
* Extend the base client implementation with an event emitter.
27+
*
28+
* The LDClientNode implementation must satisfy the LDClient interface,
29+
* which is why we extend the base client implementation with an event emitter
30+
* and then inherit from that. This adds everything we need to the implementation
31+
* to comply with the interface.
32+
*
33+
* This allows re-use of the `Emits` mixin for this and big segments.
2134
*/
22-
class LDClientNode extends LDClientImpl {
35+
class ClientBaseWithEmitter extends LDClientImpl {
2336
emitter: EventEmitter;
2437

38+
constructor(
39+
sdkKey: string,
40+
platform: Platform,
41+
options: LDOptions,
42+
callbacks: LDClientCallbacks,
43+
internalOptions?: ServerInternalOptions,
44+
) {
45+
super(sdkKey, platform, options, callbacks, internalOptions);
46+
this.emitter = new EventEmitter();
47+
}
48+
}
49+
50+
/**
51+
* @ignore
52+
*/
53+
class LDClientNode extends Emits(ClientBaseWithEmitter) implements LDClient {
2554
bigSegmentStoreStatusProvider: BigSegmentStoreStatusProvider;
2655

2756
constructor(sdkKey: string, options: LDOptions) {
@@ -32,9 +61,19 @@ class LDClientNode extends LDClientImpl {
3261
formatter: format,
3362
});
3463

35-
const emitter = new ClientEmitter();
36-
3764
const logger = options.logger ? new SafeLogger(options.logger, fallbackLogger) : fallbackLogger;
65+
const emitter = new EventEmitter();
66+
67+
const pluginValidator = TypeValidators.createTypeArray('LDPlugin', {});
68+
const plugins: LDPlugin[] = [];
69+
if (options.plugins) {
70+
if (pluginValidator.is(options.plugins)) {
71+
plugins.push(...options.plugins);
72+
} else {
73+
logger.warn('Could not validate plugins.');
74+
}
75+
}
76+
3877
super(
3978
sdkKey,
4079
new NodePlatform({ ...options, logger }),
@@ -65,13 +104,21 @@ class LDClientNode extends LDClientImpl {
65104
name === 'update' || (typeof name === 'string' && name.startsWith('update:')),
66105
),
67106
},
107+
{
108+
getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) =>
109+
internal.safeGetHooks(logger, environmentMetadata, plugins),
110+
},
68111
);
112+
// TODO: It would be good if we could re-arrange this emitter situation so we don't have to
113+
// create two emitters. It isn't harmful, but it isn't ideal.
69114
this.emitter = emitter;
70115

71116
this.bigSegmentStoreStatusProvider = new BigSegmentStoreStatusProviderNode(
72117
this.bigSegmentStatusProviderInternal,
73118
) as BigSegmentStoreStatusProvider;
119+
120+
internal.safeRegisterPlugins(logger, this.environmentMetadata, this, plugins);
74121
}
75122
}
76123

77-
export default Emits(LDClientNode);
124+
export default LDClientNode;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { LDOptions as LDOptionsCommon } from '@launchdarkly/js-server-sdk-common';
2+
3+
import { LDPlugin } from './LDPlugin';
4+
5+
/**
6+
* LaunchDarkly initialization options.
7+
*
8+
* @privateRemarks
9+
* The plugins implementation is SDK specific, so these options exist to extend the base options
10+
* with the node specific plugin configuration.
11+
*/
12+
export interface LDOptions extends LDOptionsCommon {
13+
/**
14+
* A list of plugins to be used with the SDK.
15+
*
16+
* Plugin support is currently experimental and subject to change.
17+
*/
18+
plugins?: LDPlugin[];
19+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { integrations, LDPluginBase } from '@launchdarkly/js-server-sdk-common';
2+
3+
import { LDClient } from './LDClient';
4+
5+
/**
6+
* Interface for plugins to the LaunchDarkly SDK.
7+
*
8+
* @privateRemarks
9+
* The plugin interface must be in the leaf-sdk implementations to ensure it uses the correct LDClient intrface.
10+
* The LDClient in the shared server code doesn't match the LDClient interface of the individual SDKs.
11+
*/
12+
export interface LDPlugin extends LDPluginBase<LDClient, integrations.Hook> {}

0 commit comments

Comments
 (0)