Skip to content

Commit bbdf442

Browse files
committed
chore: add node-client-sdk types, options validation, and basic logger
1 parent 99a96d2 commit bbdf442

9 files changed

Lines changed: 358 additions & 3 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { NodeOptions } from '../src/NodeOptions';
2+
import validateOptions, { filterToBaseOptions, ValidatedOptions } from '../src/options';
3+
import { createMockLogger } from './testHelpers';
4+
5+
// A value no option validator should accept regardless of the field's expected type
6+
const BOGUS_VALUE = Symbol('invalid-option-value');
7+
8+
// Exhaustive over keyof ValidatedOptions: adding a new node-specific option fails to compile
9+
// here until a bogus case is added, which forces the wrong-type-warning test below to cover
10+
// it.
11+
const wrongTypedOptions: Record<keyof ValidatedOptions, unknown> = {
12+
tlsParams: BOGUS_VALUE,
13+
enableEventCompression: BOGUS_VALUE,
14+
initialConnectionMode: BOGUS_VALUE,
15+
plugins: BOGUS_VALUE,
16+
localStoragePath: BOGUS_VALUE,
17+
hash: BOGUS_VALUE,
18+
};
19+
20+
const nodeOptionKeys = Object.keys(wrongTypedOptions) as (keyof ValidatedOptions)[];
21+
22+
let logger: ReturnType<typeof createMockLogger>;
23+
24+
beforeEach(() => {
25+
logger = createMockLogger();
26+
});
27+
28+
it('applies defaults when no node-specific options are provided', () => {
29+
const out = validateOptions({}, logger);
30+
31+
expect(out.initialConnectionMode).toBe('streaming');
32+
expect(out.plugins).toEqual([]);
33+
expect(out.tlsParams).toBeUndefined();
34+
expect(out.enableEventCompression).toBeUndefined();
35+
expect(out.localStoragePath).toBeUndefined();
36+
expect(out.hash).toBeUndefined();
37+
expect(logger.warn).not.toHaveBeenCalled();
38+
});
39+
40+
it('passes through valid node-specific options', () => {
41+
const out = validateOptions(
42+
{
43+
initialConnectionMode: 'polling',
44+
enableEventCompression: true,
45+
localStoragePath: '/tmp/ld-cache',
46+
hash: 'abc123',
47+
},
48+
logger,
49+
);
50+
51+
expect(out.initialConnectionMode).toBe('polling');
52+
expect(out.enableEventCompression).toBe(true);
53+
expect(out.localStoragePath).toBe('/tmp/ld-cache');
54+
expect(out.hash).toBe('abc123');
55+
expect(logger.warn).not.toHaveBeenCalled();
56+
});
57+
58+
it('warns and falls back to the default for an invalid initialConnectionMode', () => {
59+
const out = validateOptions({ initialConnectionMode: 'STREAMING' as any }, logger);
60+
61+
expect(out.initialConnectionMode).toBe('streaming');
62+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('initialConnectionMode'));
63+
});
64+
65+
it('warns when TLS certificate verification is disabled', () => {
66+
validateOptions({ tlsParams: { rejectUnauthorized: false } }, logger);
67+
68+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('rejectUnauthorized'));
69+
});
70+
71+
it('strips every node-specific option from the base options but keeps base options', () => {
72+
const opts: NodeOptions = {
73+
initialConnectionMode: 'polling',
74+
plugins: [],
75+
tlsParams: {},
76+
enableEventCompression: true,
77+
localStoragePath: '/tmp/ld-cache',
78+
hash: 'abc123',
79+
sendEvents: false,
80+
};
81+
82+
const base = filterToBaseOptions(opts) as Record<string, unknown>;
83+
84+
nodeOptionKeys.forEach((key) => {
85+
expect(base).not.toHaveProperty(key);
86+
});
87+
expect(base).toHaveProperty('sendEvents', false);
88+
});
89+
90+
it('warns for every validated option when given a value of the wrong type', () => {
91+
nodeOptionKeys.forEach((key) => {
92+
const fieldLogger = createMockLogger();
93+
validateOptions({ [key]: wrongTypedOptions[key] } as unknown as NodeOptions, fieldLogger);
94+
95+
expect(fieldLogger.warn).toHaveBeenCalledWith(expect.stringContaining(key));
96+
});
97+
});

packages/sdk/node-client/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@
4747
"devDependencies": {
4848
"@eslint/js": "^9.0.0",
4949
"@types/jest": "^29.4.0",
50+
"@types/node": "^25.9.1",
51+
"@typescript-eslint/eslint-plugin": "^6.20.0",
52+
"@typescript-eslint/parser": "^6.20.0",
5053
"eslint": "^9.0.0",
5154
"eslint-import-resolver-typescript": "^4.0.0",
55+
"eslint-plugin-import": "^2.27.5",
5256
"eslint-plugin-import-x": "^4.0.0",
5357
"eslint-plugin-jest": "^28.0.0",
5458
"globals": "^16.0.0",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type {
2+
ConnectionMode,
3+
LDClient as LDClientBase,
4+
LDContext,
5+
LDIdentifyOptions,
6+
LDIdentifyResult,
7+
LDStartOptions,
8+
LDWaitForInitializationResult,
9+
} from '@launchdarkly/js-client-sdk-common';
10+
11+
export type { LDStartOptions };
12+
13+
export interface LDClient extends Omit<LDClientBase, 'identify'> {
14+
/**
15+
* Identifies a context to LaunchDarkly and returns a promise which resolves to an object
16+
* containing the result of the identify operation.
17+
*
18+
* @param context The context to identify @see {@link LDContext}
19+
* @param identifyOptions Optional configuration @see {@link LDIdentifyOptions}.
20+
* @returns an identify result @see {@link LDIdentifyResult}
21+
*/
22+
identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<LDIdentifyResult>;
23+
24+
/**
25+
* Starts the client by performing the first identify with the initial context. Must be
26+
* called after {@link createClient}. The returned promise resolves when the first
27+
* identify completes (or times out, or fails).
28+
*
29+
* @param options Optional configuration. See {@link LDStartOptions}.
30+
*/
31+
start(options?: LDStartOptions): Promise<LDWaitForInitializationResult>;
32+
33+
/**
34+
* Sets the data source connection mode.
35+
*
36+
* @see {@link ConnectionMode}
37+
*/
38+
setConnectionMode(mode: ConnectionMode): Promise<void>;
39+
40+
/**
41+
* Returns the current data source connection mode.
42+
*/
43+
getConnectionMode(): ConnectionMode;
44+
45+
/**
46+
* Returns true if the client is in offline mode.
47+
*/
48+
isOffline(): boolean;
49+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export type {
2+
LDIdentifyOptions,
3+
AutoEnvAttributes,
4+
BasicLogger,
5+
BasicLoggerOptions,
6+
EvaluationSeriesContext,
7+
EvaluationSeriesData,
8+
Hook,
9+
HookMetadata,
10+
IdentifySeriesContext,
11+
IdentifySeriesData,
12+
IdentifySeriesResult,
13+
IdentifySeriesStatus,
14+
LDContext,
15+
LDContextCommon,
16+
LDContextMeta,
17+
LDContextStrict,
18+
LDEvaluationDetail,
19+
LDEvaluationDetailTyped,
20+
LDEvaluationReason,
21+
LDFlagSet,
22+
LDFlagValue,
23+
LDTimeoutError,
24+
LDInspection,
25+
LDLogger,
26+
LDLogLevel,
27+
LDMultiKindContext,
28+
LDSingleKindContext,
29+
TrackSeriesContext,
30+
LDPluginBase,
31+
LDPluginEnvironmentMetadata,
32+
LDPluginSdkMetadata,
33+
LDPluginApplicationMetadata,
34+
LDPluginMetadata,
35+
LDIdentifyResult,
36+
LDIdentifySuccess,
37+
LDIdentifyError,
38+
LDIdentifyTimeout,
39+
LDIdentifyShed,
40+
LDDebugOverride,
41+
LDWaitForInitializationOptions,
42+
LDWaitForInitializationResult,
43+
LDWaitForInitializationComplete,
44+
LDWaitForInitializationFailed,
45+
LDWaitForInitializationTimeout,
46+
} from '@launchdarkly/js-client-sdk-common';
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Hook, LDPluginBase } from '@launchdarkly/js-client-sdk-common';
2+
3+
import { LDClient } from './LDClient';
4+
5+
/**
6+
* Interface for plugins to the LaunchDarkly SDK.
7+
*/
8+
export interface LDPlugin extends LDPluginBase<LDClient, Hook> {}

packages/sdk/node-client/src/NodeOptions.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { ConnectionMode, LDOptions as LDOptionsBase } from '@launchdarkly/js-client-sdk-common';
2+
3+
import type { LDPlugin } from './LDPlugin';
4+
15
/**
26
* Additional parameters to pass to the Node HTTPS API for secure requests. These can include any
37
* of the TLS-related parameters supported by `https.request()`, such as `ca`, `cert`, and `key`.
@@ -20,7 +24,7 @@ export interface LDTLSOptions {
2024
/**
2125
* Configuration options for the Node client-side SDK.
2226
*/
23-
export interface NodeOptions {
27+
export interface NodeOptions extends LDOptionsBase {
2428
/**
2529
* Additional parameters to pass to the Node HTTPS API for secure requests. These can include any
2630
* of the TLS-related parameters supported by `https.request()`, such as `ca`, `cert`, and `key`.
@@ -42,4 +46,26 @@ export interface NodeOptions {
4246
* Defaults to `<cwd>/ldclient-user-cache`.
4347
*/
4448
localStoragePath?: string;
49+
50+
/**
51+
* Sets the mode to use for connections when the SDK is initialized.
52+
*
53+
* @remarks
54+
* Possible values are offline, streaming, or polling. See {@link ConnectionMode} for more information.
55+
*
56+
* Defaults to streaming.
57+
*/
58+
initialConnectionMode?: ConnectionMode;
59+
60+
/**
61+
* A list of plugins to be used with the SDK.
62+
*/
63+
plugins?: LDPlugin[];
64+
65+
/**
66+
* The Secure Mode hash for the configured context.
67+
*
68+
* @see https://docs.launchdarkly.com/sdk/features/secure-mode
69+
*/
70+
hash?: string;
4571
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { format } from 'util';
2+
3+
import {
4+
BasicLogger,
5+
BasicLoggerOptions,
6+
LDLogger,
7+
} from '@launchdarkly/js-client-sdk-common';
8+
9+
/**
10+
* Provides a basic {@link LDLogger} implementation.
11+
*
12+
* Output is written to `console.log` using Node's `util.format` so multiple arguments and
13+
* format specifiers (`%s`, `%d`, etc.) are formatted the way Node consumers expect.
14+
*
15+
* If you do not pass a logger via {@link LDOptions.logger}, the SDK falls back to
16+
* a logger equivalent to `basicLogger({ level: 'info' })`.
17+
*
18+
* @example
19+
* ```javascript
20+
* const ldOptions = {
21+
* logger: basicLogger({ level: 'warn' }),
22+
* };
23+
* ```
24+
*/
25+
export default function basicLogger(options: BasicLoggerOptions = {}): LDLogger {
26+
return new BasicLogger({
27+
...options,
28+
destination:
29+
options.destination ??
30+
((line: string) => {
31+
// eslint-disable-next-line no-console
32+
console.log(line);
33+
}),
34+
formatter: options.formatter ?? format,
35+
});
36+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {
2+
ConnectionMode,
3+
LDLogger,
4+
LDOptions as LDOptionsBase,
5+
OptionMessages,
6+
TypeValidator,
7+
TypeValidators,
8+
} from '@launchdarkly/js-client-sdk-common';
9+
10+
import type { LDTLSOptions, NodeOptions } from './NodeOptions';
11+
import type { LDPlugin } from './LDPlugin';
12+
13+
class ConnectionModeValidator implements TypeValidator {
14+
is(u: unknown): u is ConnectionMode {
15+
return u === 'offline' || u === 'streaming' || u === 'polling';
16+
}
17+
getType(): string {
18+
return 'ConnectionMode (offline | streaming | polling)';
19+
}
20+
}
21+
22+
export interface ValidatedOptions {
23+
tlsParams?: LDTLSOptions;
24+
enableEventCompression?: boolean;
25+
initialConnectionMode: ConnectionMode;
26+
plugins: LDPlugin[];
27+
localStoragePath?: string;
28+
hash?: string;
29+
}
30+
31+
const optDefaults: ValidatedOptions = {
32+
tlsParams: undefined,
33+
enableEventCompression: undefined,
34+
initialConnectionMode: 'streaming',
35+
plugins: [],
36+
localStoragePath: undefined,
37+
hash: undefined,
38+
};
39+
40+
// Keyed off ValidatedOptions so adding a Node-specific option fails to compile until a
41+
// validator is registered here (and a default in optDefaults), forcing validation/logging
42+
// coverage for the new field.
43+
const validators: Record<keyof ValidatedOptions, TypeValidator> = {
44+
tlsParams: TypeValidators.Object,
45+
enableEventCompression: TypeValidators.Boolean,
46+
initialConnectionMode: new ConnectionModeValidator(),
47+
plugins: TypeValidators.createTypeArray('LDPlugin[]', {}),
48+
localStoragePath: TypeValidators.String,
49+
hash: TypeValidators.String,
50+
};
51+
52+
export function filterToBaseOptions(opts: NodeOptions): LDOptionsBase {
53+
const baseOptions: LDOptionsBase = { ...opts };
54+
55+
// Strip Node-specific keys so the common options validator does not warn about them.
56+
Object.keys(optDefaults).forEach((key) => {
57+
delete (baseOptions as any)[key];
58+
});
59+
return baseOptions;
60+
}
61+
62+
export default function validateOptions(opts: NodeOptions, logger: LDLogger): ValidatedOptions {
63+
const output: ValidatedOptions = { ...optDefaults };
64+
65+
Object.entries(validators).forEach((entry) => {
66+
const [key, validator] = entry as [keyof ValidatedOptions, TypeValidator];
67+
const value = opts[key];
68+
if (value !== undefined) {
69+
if (validator.is(value)) {
70+
// @ts-ignore The type inference has some problems here.
71+
output[key as keyof ValidatedOptions] = value as any;
72+
} else {
73+
logger.warn(OptionMessages.wrongOptionType(key, validator.getType(), typeof value));
74+
}
75+
}
76+
});
77+
78+
if (output.tlsParams?.rejectUnauthorized === false) {
79+
logger.warn(
80+
'TLS certificate verification is disabled via tlsParams.rejectUnauthorized=false. ' +
81+
'This is insecure and should not be used in production.',
82+
);
83+
}
84+
85+
return output;
86+
}

0 commit comments

Comments
 (0)