From bbdf442421f19e9e2eb533f03a401d7bcc499371 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 2 Jun 2026 11:52:14 -0400 Subject: [PATCH 1/3] chore: add node-client-sdk types, options validation, and basic logger --- .../sdk/node-client/__tests__/options.test.ts | 97 +++++++++++++++++++ packages/sdk/node-client/package.json | 4 + packages/sdk/node-client/src/LDClient.ts | 49 ++++++++++ packages/sdk/node-client/src/LDCommon.ts | 46 +++++++++ packages/sdk/node-client/src/LDPlugin.ts | 8 ++ packages/sdk/node-client/src/NodeOptions.ts | 28 +++++- packages/sdk/node-client/src/basicLogger.ts | 36 +++++++ packages/sdk/node-client/src/options.ts | 86 ++++++++++++++++ .../node-client/src/platform/NodePlatform.ts | 7 +- 9 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/node-client/__tests__/options.test.ts create mode 100644 packages/sdk/node-client/src/LDClient.ts create mode 100644 packages/sdk/node-client/src/LDCommon.ts create mode 100644 packages/sdk/node-client/src/LDPlugin.ts create mode 100644 packages/sdk/node-client/src/basicLogger.ts create mode 100644 packages/sdk/node-client/src/options.ts diff --git a/packages/sdk/node-client/__tests__/options.test.ts b/packages/sdk/node-client/__tests__/options.test.ts new file mode 100644 index 0000000000..5e6b6f1cc5 --- /dev/null +++ b/packages/sdk/node-client/__tests__/options.test.ts @@ -0,0 +1,97 @@ +import type { NodeOptions } from '../src/NodeOptions'; +import validateOptions, { filterToBaseOptions, ValidatedOptions } from '../src/options'; +import { createMockLogger } from './testHelpers'; + +// A value no option validator should accept regardless of the field's expected type +const BOGUS_VALUE = Symbol('invalid-option-value'); + +// Exhaustive over keyof ValidatedOptions: adding a new node-specific option fails to compile +// here until a bogus case is added, which forces the wrong-type-warning test below to cover +// it. +const wrongTypedOptions: Record = { + tlsParams: BOGUS_VALUE, + enableEventCompression: BOGUS_VALUE, + initialConnectionMode: BOGUS_VALUE, + plugins: BOGUS_VALUE, + localStoragePath: BOGUS_VALUE, + hash: BOGUS_VALUE, +}; + +const nodeOptionKeys = Object.keys(wrongTypedOptions) as (keyof ValidatedOptions)[]; + +let logger: ReturnType; + +beforeEach(() => { + logger = createMockLogger(); +}); + +it('applies defaults when no node-specific options are provided', () => { + const out = validateOptions({}, logger); + + expect(out.initialConnectionMode).toBe('streaming'); + expect(out.plugins).toEqual([]); + expect(out.tlsParams).toBeUndefined(); + expect(out.enableEventCompression).toBeUndefined(); + expect(out.localStoragePath).toBeUndefined(); + expect(out.hash).toBeUndefined(); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('passes through valid node-specific options', () => { + const out = validateOptions( + { + initialConnectionMode: 'polling', + enableEventCompression: true, + localStoragePath: '/tmp/ld-cache', + hash: 'abc123', + }, + logger, + ); + + expect(out.initialConnectionMode).toBe('polling'); + expect(out.enableEventCompression).toBe(true); + expect(out.localStoragePath).toBe('/tmp/ld-cache'); + expect(out.hash).toBe('abc123'); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('warns and falls back to the default for an invalid initialConnectionMode', () => { + const out = validateOptions({ initialConnectionMode: 'STREAMING' as any }, logger); + + expect(out.initialConnectionMode).toBe('streaming'); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('initialConnectionMode')); +}); + +it('warns when TLS certificate verification is disabled', () => { + validateOptions({ tlsParams: { rejectUnauthorized: false } }, logger); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('rejectUnauthorized')); +}); + +it('strips every node-specific option from the base options but keeps base options', () => { + const opts: NodeOptions = { + initialConnectionMode: 'polling', + plugins: [], + tlsParams: {}, + enableEventCompression: true, + localStoragePath: '/tmp/ld-cache', + hash: 'abc123', + sendEvents: false, + }; + + const base = filterToBaseOptions(opts) as Record; + + nodeOptionKeys.forEach((key) => { + expect(base).not.toHaveProperty(key); + }); + expect(base).toHaveProperty('sendEvents', false); +}); + +it('warns for every validated option when given a value of the wrong type', () => { + nodeOptionKeys.forEach((key) => { + const fieldLogger = createMockLogger(); + validateOptions({ [key]: wrongTypedOptions[key] } as unknown as NodeOptions, fieldLogger); + + expect(fieldLogger.warn).toHaveBeenCalledWith(expect.stringContaining(key)); + }); +}); diff --git a/packages/sdk/node-client/package.json b/packages/sdk/node-client/package.json index bd946bc25f..b6119d3251 100644 --- a/packages/sdk/node-client/package.json +++ b/packages/sdk/node-client/package.json @@ -47,8 +47,12 @@ "devDependencies": { "@eslint/js": "^9.0.0", "@types/jest": "^29.4.0", + "@types/node": "^25.9.1", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", "eslint": "^9.0.0", "eslint-import-resolver-typescript": "^4.0.0", + "eslint-plugin-import": "^2.27.5", "eslint-plugin-import-x": "^4.0.0", "eslint-plugin-jest": "^28.0.0", "globals": "^16.0.0", diff --git a/packages/sdk/node-client/src/LDClient.ts b/packages/sdk/node-client/src/LDClient.ts new file mode 100644 index 0000000000..88b301cc0d --- /dev/null +++ b/packages/sdk/node-client/src/LDClient.ts @@ -0,0 +1,49 @@ +import type { + ConnectionMode, + LDClient as LDClientBase, + LDContext, + LDIdentifyOptions, + LDIdentifyResult, + LDStartOptions, + LDWaitForInitializationResult, +} from '@launchdarkly/js-client-sdk-common'; + +export type { LDStartOptions }; + +export interface LDClient extends Omit { + /** + * Identifies a context to LaunchDarkly and returns a promise which resolves to an object + * containing the result of the identify operation. + * + * @param context The context to identify @see {@link LDContext} + * @param identifyOptions Optional configuration @see {@link LDIdentifyOptions}. + * @returns an identify result @see {@link LDIdentifyResult} + */ + identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise; + + /** + * Starts the client by performing the first identify with the initial context. Must be + * called after {@link createClient}. The returned promise resolves when the first + * identify completes (or times out, or fails). + * + * @param options Optional configuration. See {@link LDStartOptions}. + */ + start(options?: LDStartOptions): Promise; + + /** + * Sets the data source connection mode. + * + * @see {@link ConnectionMode} + */ + setConnectionMode(mode: ConnectionMode): Promise; + + /** + * Returns the current data source connection mode. + */ + getConnectionMode(): ConnectionMode; + + /** + * Returns true if the client is in offline mode. + */ + isOffline(): boolean; +} diff --git a/packages/sdk/node-client/src/LDCommon.ts b/packages/sdk/node-client/src/LDCommon.ts new file mode 100644 index 0000000000..09d4dcd501 --- /dev/null +++ b/packages/sdk/node-client/src/LDCommon.ts @@ -0,0 +1,46 @@ +export type { + LDIdentifyOptions, + AutoEnvAttributes, + BasicLogger, + BasicLoggerOptions, + EvaluationSeriesContext, + EvaluationSeriesData, + Hook, + HookMetadata, + IdentifySeriesContext, + IdentifySeriesData, + IdentifySeriesResult, + IdentifySeriesStatus, + LDContext, + LDContextCommon, + LDContextMeta, + LDContextStrict, + LDEvaluationDetail, + LDEvaluationDetailTyped, + LDEvaluationReason, + LDFlagSet, + LDFlagValue, + LDTimeoutError, + LDInspection, + LDLogger, + LDLogLevel, + LDMultiKindContext, + LDSingleKindContext, + TrackSeriesContext, + LDPluginBase, + LDPluginEnvironmentMetadata, + LDPluginSdkMetadata, + LDPluginApplicationMetadata, + LDPluginMetadata, + LDIdentifyResult, + LDIdentifySuccess, + LDIdentifyError, + LDIdentifyTimeout, + LDIdentifyShed, + LDDebugOverride, + LDWaitForInitializationOptions, + LDWaitForInitializationResult, + LDWaitForInitializationComplete, + LDWaitForInitializationFailed, + LDWaitForInitializationTimeout, +} from '@launchdarkly/js-client-sdk-common'; diff --git a/packages/sdk/node-client/src/LDPlugin.ts b/packages/sdk/node-client/src/LDPlugin.ts new file mode 100644 index 0000000000..de06f6db30 --- /dev/null +++ b/packages/sdk/node-client/src/LDPlugin.ts @@ -0,0 +1,8 @@ +import { Hook, LDPluginBase } from '@launchdarkly/js-client-sdk-common'; + +import { LDClient } from './LDClient'; + +/** + * Interface for plugins to the LaunchDarkly SDK. + */ +export interface LDPlugin extends LDPluginBase {} diff --git a/packages/sdk/node-client/src/NodeOptions.ts b/packages/sdk/node-client/src/NodeOptions.ts index 39efba4950..bc48a1a324 100644 --- a/packages/sdk/node-client/src/NodeOptions.ts +++ b/packages/sdk/node-client/src/NodeOptions.ts @@ -1,3 +1,7 @@ +import { ConnectionMode, LDOptions as LDOptionsBase } from '@launchdarkly/js-client-sdk-common'; + +import type { LDPlugin } from './LDPlugin'; + /** * Additional parameters to pass to the Node HTTPS API for secure requests. These can include any * of the TLS-related parameters supported by `https.request()`, such as `ca`, `cert`, and `key`. @@ -20,7 +24,7 @@ export interface LDTLSOptions { /** * Configuration options for the Node client-side SDK. */ -export interface NodeOptions { +export interface NodeOptions extends LDOptionsBase { /** * Additional parameters to pass to the Node HTTPS API for secure requests. These can include any * of the TLS-related parameters supported by `https.request()`, such as `ca`, `cert`, and `key`. @@ -42,4 +46,26 @@ export interface NodeOptions { * Defaults to `/ldclient-user-cache`. */ localStoragePath?: string; + + /** + * Sets the mode to use for connections when the SDK is initialized. + * + * @remarks + * Possible values are offline, streaming, or polling. See {@link ConnectionMode} for more information. + * + * Defaults to streaming. + */ + initialConnectionMode?: ConnectionMode; + + /** + * A list of plugins to be used with the SDK. + */ + plugins?: LDPlugin[]; + + /** + * The Secure Mode hash for the configured context. + * + * @see https://docs.launchdarkly.com/sdk/features/secure-mode + */ + hash?: string; } diff --git a/packages/sdk/node-client/src/basicLogger.ts b/packages/sdk/node-client/src/basicLogger.ts new file mode 100644 index 0000000000..f9709832b8 --- /dev/null +++ b/packages/sdk/node-client/src/basicLogger.ts @@ -0,0 +1,36 @@ +import { format } from 'util'; + +import { + BasicLogger, + BasicLoggerOptions, + LDLogger, +} from '@launchdarkly/js-client-sdk-common'; + +/** + * Provides a basic {@link LDLogger} implementation. + * + * Output is written to `console.log` using Node's `util.format` so multiple arguments and + * format specifiers (`%s`, `%d`, etc.) are formatted the way Node consumers expect. + * + * If you do not pass a logger via {@link LDOptions.logger}, the SDK falls back to + * a logger equivalent to `basicLogger({ level: 'info' })`. + * + * @example + * ```javascript + * const ldOptions = { + * logger: basicLogger({ level: 'warn' }), + * }; + * ``` + */ +export default function basicLogger(options: BasicLoggerOptions = {}): LDLogger { + return new BasicLogger({ + ...options, + destination: + options.destination ?? + ((line: string) => { + // eslint-disable-next-line no-console + console.log(line); + }), + formatter: options.formatter ?? format, + }); +} diff --git a/packages/sdk/node-client/src/options.ts b/packages/sdk/node-client/src/options.ts new file mode 100644 index 0000000000..0931faab99 --- /dev/null +++ b/packages/sdk/node-client/src/options.ts @@ -0,0 +1,86 @@ +import { + ConnectionMode, + LDLogger, + LDOptions as LDOptionsBase, + OptionMessages, + TypeValidator, + TypeValidators, +} from '@launchdarkly/js-client-sdk-common'; + +import type { LDTLSOptions, NodeOptions } from './NodeOptions'; +import type { LDPlugin } from './LDPlugin'; + +class ConnectionModeValidator implements TypeValidator { + is(u: unknown): u is ConnectionMode { + return u === 'offline' || u === 'streaming' || u === 'polling'; + } + getType(): string { + return 'ConnectionMode (offline | streaming | polling)'; + } +} + +export interface ValidatedOptions { + tlsParams?: LDTLSOptions; + enableEventCompression?: boolean; + initialConnectionMode: ConnectionMode; + plugins: LDPlugin[]; + localStoragePath?: string; + hash?: string; +} + +const optDefaults: ValidatedOptions = { + tlsParams: undefined, + enableEventCompression: undefined, + initialConnectionMode: 'streaming', + plugins: [], + localStoragePath: undefined, + hash: undefined, +}; + +// Keyed off ValidatedOptions so adding a Node-specific option fails to compile until a +// validator is registered here (and a default in optDefaults), forcing validation/logging +// coverage for the new field. +const validators: Record = { + tlsParams: TypeValidators.Object, + enableEventCompression: TypeValidators.Boolean, + initialConnectionMode: new ConnectionModeValidator(), + plugins: TypeValidators.createTypeArray('LDPlugin[]', {}), + localStoragePath: TypeValidators.String, + hash: TypeValidators.String, +}; + +export function filterToBaseOptions(opts: NodeOptions): LDOptionsBase { + const baseOptions: LDOptionsBase = { ...opts }; + + // Strip Node-specific keys so the common options validator does not warn about them. + Object.keys(optDefaults).forEach((key) => { + delete (baseOptions as any)[key]; + }); + return baseOptions; +} + +export default function validateOptions(opts: NodeOptions, logger: LDLogger): ValidatedOptions { + const output: ValidatedOptions = { ...optDefaults }; + + Object.entries(validators).forEach((entry) => { + const [key, validator] = entry as [keyof ValidatedOptions, TypeValidator]; + const value = opts[key]; + if (value !== undefined) { + if (validator.is(value)) { + // @ts-ignore The type inference has some problems here. + output[key as keyof ValidatedOptions] = value as any; + } else { + logger.warn(OptionMessages.wrongOptionType(key, validator.getType(), typeof value)); + } + } + }); + + if (output.tlsParams?.rejectUnauthorized === false) { + logger.warn( + 'TLS certificate verification is disabled via tlsParams.rejectUnauthorized=false. ' + + 'This is insecure and should not be used in production.', + ); + } + + return output; +} diff --git a/packages/sdk/node-client/src/platform/NodePlatform.ts b/packages/sdk/node-client/src/platform/NodePlatform.ts index eab1c79671..e73913fadb 100644 --- a/packages/sdk/node-client/src/platform/NodePlatform.ts +++ b/packages/sdk/node-client/src/platform/NodePlatform.ts @@ -1,6 +1,6 @@ import { LDLogger, platform } from '@launchdarkly/js-client-sdk-common'; -import type { NodeOptions } from '../NodeOptions'; +import type { ValidatedOptions } from '../options'; import NodeCrypto from './NodeCrypto'; import NodeEncoding from './NodeEncoding'; import NodeInfo from './NodeInfo'; @@ -18,7 +18,10 @@ export default class NodePlatform implements platform.Platform { requests: platform.Requests; - constructor(logger: LDLogger, options: NodeOptions) { + constructor( + logger: LDLogger, + options: Pick, + ) { this.storage = getNodeStorage(options.localStoragePath, logger); this.requests = new NodeRequests(options.tlsParams, options.enableEventCompression); } From 815139a1becb748df8985a62c84e410bc70c3755 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 3 Jun 2026 17:44:50 -0400 Subject: [PATCH 2/3] chore: remove unused deps This cleans up unused dependencies after we upgraded to eslint 9 --- packages/sdk/node-client/.eslintrc.cjs | 11 ----------- packages/sdk/node-client/package.json | 3 --- 2 files changed, 14 deletions(-) delete mode 100644 packages/sdk/node-client/.eslintrc.cjs diff --git a/packages/sdk/node-client/.eslintrc.cjs b/packages/sdk/node-client/.eslintrc.cjs deleted file mode 100644 index b3ffc9a0de..0000000000 --- a/packages/sdk/node-client/.eslintrc.cjs +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - overrides: [ - { - files: ['contract-tests/**/*.ts'], - parserOptions: { - tsconfigRootDir: __dirname, - project: './contract-tests/tsconfig.json', - }, - }, - ], -}; diff --git a/packages/sdk/node-client/package.json b/packages/sdk/node-client/package.json index b6119d3251..6f88e9da3d 100644 --- a/packages/sdk/node-client/package.json +++ b/packages/sdk/node-client/package.json @@ -48,11 +48,8 @@ "@eslint/js": "^9.0.0", "@types/jest": "^29.4.0", "@types/node": "^25.9.1", - "@typescript-eslint/eslint-plugin": "^6.20.0", - "@typescript-eslint/parser": "^6.20.0", "eslint": "^9.0.0", "eslint-import-resolver-typescript": "^4.0.0", - "eslint-plugin-import": "^2.27.5", "eslint-plugin-import-x": "^4.0.0", "eslint-plugin-jest": "^28.0.0", "globals": "^16.0.0", From cea18dc5395ac4ca5ec40333190c241ba1a5ed11 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 4 Jun 2026 12:35:24 -0400 Subject: [PATCH 3/3] chore: pr comments --- packages/sdk/node-client/src/LDClient.ts | 27 ++++++++++++++++----- packages/sdk/node-client/src/basicLogger.ts | 16 +++++++----- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/sdk/node-client/src/LDClient.ts b/packages/sdk/node-client/src/LDClient.ts index 88b301cc0d..69c7b7c8ee 100644 --- a/packages/sdk/node-client/src/LDClient.ts +++ b/packages/sdk/node-client/src/LDClient.ts @@ -15,16 +15,27 @@ export interface LDClient extends Omit { * Identifies a context to LaunchDarkly and returns a promise which resolves to an object * containing the result of the identify operation. * - * @param context The context to identify @see {@link LDContext} - * @param identifyOptions Optional configuration @see {@link LDIdentifyOptions}. - * @returns an identify result @see {@link LDIdentifyResult} + * Unlike the server-side SDKs, the client-side Node.js SDK maintains a current context + * state, which is set when you call `identify()`. + * + * Changing the current context also causes all feature flag values to be reloaded. Until + * that has finished, calls to variation methods will still return flag values for the + * previous context. You can await the Promise to determine when the new flag values are + * available. + * + * Use {@link start} to set the initial context at startup. + * + * @param context The context to identify. @see {@link LDContext} + * @param identifyOptions Optional configuration. @see {@link LDIdentifyOptions}. + * @returns A promise which resolves to an object containing the result of the identify operation. */ identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise; /** - * Starts the client by performing the first identify with the initial context. Must be - * called after {@link createClient}. The returned promise resolves when the first - * identify completes (or times out, or fails). + * Starts the client and returns a promise that resolves to the initialization result. + * + * The promise will resolve to a {@link LDWaitForInitializationResult} object containing the + * status of the waitForInitialization operation. * * @param options Optional configuration. See {@link LDStartOptions}. */ @@ -33,6 +44,10 @@ export interface LDClient extends Omit { /** * Sets the data source connection mode. * + * @remarks + * Switches between 'offline', 'streaming', and 'polling' at runtime without restarting + * the client. Use 'offline' to pause all LaunchDarkly network activity. + * * @see {@link ConnectionMode} */ setConnectionMode(mode: ConnectionMode): Promise; diff --git a/packages/sdk/node-client/src/basicLogger.ts b/packages/sdk/node-client/src/basicLogger.ts index f9709832b8..6ecc0e3efa 100644 --- a/packages/sdk/node-client/src/basicLogger.ts +++ b/packages/sdk/node-client/src/basicLogger.ts @@ -25,12 +25,16 @@ import { export default function basicLogger(options: BasicLoggerOptions = {}): LDLogger { return new BasicLogger({ ...options, - destination: - options.destination ?? - ((line: string) => { - // eslint-disable-next-line no-console - console.log(line); - }), + destination: options.destination ?? { + // eslint-disable-next-line no-console + debug: console.debug, + // eslint-disable-next-line no-console + info: console.info, + // eslint-disable-next-line no-console + warn: console.warn, + // eslint-disable-next-line no-console + error: console.error, + }, formatter: options.formatter ?? format, }); }