From dd6f4f6c491dd062d4a5b6d19958caac5b6c90c4 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 29 May 2026 12:17:18 -0400 Subject: [PATCH 1/7] chore: Port node-client onto @launchdarkly/js-client-sdk-common --- .../sdk/node-client/__tests__/index.test.ts | 6 + packages/sdk/node-client/package.json | 1 + packages/sdk/node-client/src/LDClient.ts | 39 +++ packages/sdk/node-client/src/LDCommon.ts | 46 ++++ packages/sdk/node-client/src/LDPlugin.ts | 8 + packages/sdk/node-client/src/NodeClient.ts | 177 +++++++++++++ .../sdk/node-client/src/NodeDataManager.ts | 203 +++++++++++++++ packages/sdk/node-client/src/NodeOptions.ts | 30 ++- packages/sdk/node-client/src/basicLogger.ts | 36 +++ packages/sdk/node-client/src/index.ts | 53 +++- packages/sdk/node-client/src/options.ts | 76 ++++++ .../sdk/node-client/temp_docs/MIGRATION.md | 242 ++++++++++++++++++ 12 files changed, 912 insertions(+), 5 deletions(-) create mode 100644 packages/sdk/node-client/__tests__/index.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/NodeClient.ts create mode 100644 packages/sdk/node-client/src/NodeDataManager.ts create mode 100644 packages/sdk/node-client/src/basicLogger.ts create mode 100644 packages/sdk/node-client/src/options.ts create mode 100644 packages/sdk/node-client/temp_docs/MIGRATION.md diff --git a/packages/sdk/node-client/__tests__/index.test.ts b/packages/sdk/node-client/__tests__/index.test.ts new file mode 100644 index 0000000000..e183c6cfad --- /dev/null +++ b/packages/sdk/node-client/__tests__/index.test.ts @@ -0,0 +1,6 @@ +import * as ld from '../src'; + +it('exports a version string', () => { + expect(typeof ld.version).toBe('string'); + expect(ld.version.length).toBeGreaterThan(0); +}); diff --git a/packages/sdk/node-client/package.json b/packages/sdk/node-client/package.json index 02168076ed..bd42a99e5e 100644 --- a/packages/sdk/node-client/package.json +++ b/packages/sdk/node-client/package.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@types/jest": "^29.4.0", + "@types/node": "^25.9.1", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", "eslint": "^8.45.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..92e7a5cd65 --- /dev/null +++ b/packages/sdk/node-client/src/LDClient.ts @@ -0,0 +1,39 @@ +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. Optionally accepts bootstrap data so that + * the identify operation completes without waiting for the network. + * + * @param context The context to identify. + * @param identifyOptions Optional configuration including {@link LDIdentifyOptions.bootstrap}. + * @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). + * + * @param options Optional configuration. See {@link LDStartOptions}. + */ + start(options?: LDStartOptions): Promise; + + setConnectionMode(mode: ConnectionMode): Promise; + + getConnectionMode(): ConnectionMode; + + 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/NodeClient.ts b/packages/sdk/node-client/src/NodeClient.ts new file mode 100644 index 0000000000..4a6ea177ce --- /dev/null +++ b/packages/sdk/node-client/src/NodeClient.ts @@ -0,0 +1,177 @@ +import { + AutoEnvAttributes, + browserFdv1Endpoints, + Configuration, + ConnectionMode, + FlagManager, + internal, + LDClientImpl, + LDClientInternalOptions, + LDContext, + LDEmitter, + LDEmitterEventName, + LDFlagValue, + LDHeaders, + LDIdentifyOptions, + LDIdentifyResult, + LDPluginEnvironmentMetadata, +} from '@launchdarkly/js-client-sdk-common'; + +import basicLogger from './basicLogger'; +import type { LDClient, LDStartOptions } from './LDClient'; +import type { LDPlugin } from './LDPlugin'; +import NodeDataManager from './NodeDataManager'; +import type { NodeOptions } from './NodeOptions'; +import validateOptions, { filterToBaseOptions } from './options'; +import NodePlatform from './platform/NodePlatform'; + +export class NodeClient extends LDClientImpl { + private readonly _plugins: LDPlugin[]; + + constructor(envKey: string, initialContext: LDContext, options: NodeOptions = {}) { + const { logger: customLogger, debug } = options; + const logger = customLogger ?? basicLogger({ level: debug ? 'debug' : 'info' }); + + const validatedNodeOptions = validateOptions(options, logger); + + const internalOptions: LDClientInternalOptions = { + analyticsEventPath: `/events/bulk/${envKey}`, + diagnosticEventPath: `/events/diagnostic/${envKey}`, + highTimeoutThreshold: 15, + getImplementationHooks: (_environmentMetadata: LDPluginEnvironmentMetadata) => + internal.safeGetHooks(logger, _environmentMetadata, validatedNodeOptions.plugins), + credentialType: 'clientSideId', + requiresStart: true, + initialContext, + }; + + const platform = new NodePlatform(logger, options); + const endpoints = browserFdv1Endpoints(envKey); + + super( + envKey, + AutoEnvAttributes.Disabled, + platform, + { ...filterToBaseOptions(options), logger }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new NodeDataManager( + platform, + flagManager, + envKey, + configuration, + validatedNodeOptions, + endpoints.polling, + endpoints.streaming, + baseHeaders, + emitter, + diagnosticsManager, + ), + internalOptions, + ); + + this._plugins = validatedNodeOptions.plugins; + this.setEventSendingEnabled(!this.isOffline(), false); + } + + /** + * Registers plugins with the public client facade so plugins receive the + * public API (single identify that returns LDIdentifyResult). + */ + registerPluginsWith(client: LDClient): void { + internal.safeRegisterPlugins(this.logger, this.environmentMetadata, client, this._plugins); + } + + override async identifyResult( + context: LDContext, + identifyOptions?: LDIdentifyOptions, + ): Promise { + const options = + identifyOptions?.sheddable === undefined + ? { ...identifyOptions, sheddable: true } + : identifyOptions; + return super.identifyResult(context, options); + } + + async setConnectionMode(mode: ConnectionMode): Promise { + if (mode === 'offline') { + this.setEventSendingEnabled(false, true); + } + const dataManager = this.dataManager as NodeDataManager; + await dataManager.setConnectionMode(mode); + if (mode !== 'offline') { + this.setEventSendingEnabled(true, false); + } + } + + getConnectionMode(): ConnectionMode { + const dataManager = this.dataManager as NodeDataManager; + return dataManager.getConnectionMode(); + } + + isOffline(): boolean { + const dataManager = this.dataManager as NodeDataManager; + return dataManager.getConnectionMode() === 'offline'; + } +} + +/** + * Builds the LaunchDarkly client facade (PIMPL). Exposes a single identify + * method that returns identify results. The client is not started; the caller + * must call `client.start()`. + */ +export function makeClient( + envKey: string, + initialContext: LDContext, + options: NodeOptions = {}, +): LDClient { + const impl = new NodeClient(envKey, initialContext, options); + + const client: LDClient = { + variation: (key: string, defaultValue?: LDFlagValue) => impl.variation(key, defaultValue), + variationDetail: (key: string, defaultValue?: LDFlagValue) => + impl.variationDetail(key, defaultValue), + boolVariation: (key: string, defaultValue: boolean) => impl.boolVariation(key, defaultValue), + boolVariationDetail: (key: string, defaultValue: boolean) => + impl.boolVariationDetail(key, defaultValue), + numberVariation: (key: string, defaultValue: number) => impl.numberVariation(key, defaultValue), + numberVariationDetail: (key: string, defaultValue: number) => + impl.numberVariationDetail(key, defaultValue), + stringVariation: (key: string, defaultValue: string) => impl.stringVariation(key, defaultValue), + stringVariationDetail: (key: string, defaultValue: string) => + impl.stringVariationDetail(key, defaultValue), + jsonVariation: (key: string, defaultValue: unknown) => impl.jsonVariation(key, defaultValue), + jsonVariationDetail: (key: string, defaultValue: unknown) => + impl.jsonVariationDetail(key, defaultValue), + track: (key: string, data?: unknown, metricValue?: number) => + impl.track(key, data, metricValue), + on: (key: string, callback: (...args: unknown[]) => void) => + impl.on(key as LDEmitterEventName, callback as (...args: unknown[]) => void), + off: (key: string, callback: (...args: unknown[]) => void) => + impl.off(key as LDEmitterEventName, callback as (...args: unknown[]) => void), + flush: () => impl.flush(), + identify: (ctx: LDContext, identifyOptions?: LDIdentifyOptions) => + impl.identifyResult(ctx, identifyOptions), + getContext: () => impl.getContext(), + close: () => impl.close(), + allFlags: () => impl.allFlags(), + addHook: (hook: Parameters[0]) => impl.addHook(hook), + waitForInitialization: (waitOptions?: Parameters[0]) => + impl.waitForInitialization(waitOptions), + logger: impl.logger, + start: (startOptions?: LDStartOptions) => impl.start(startOptions), + setConnectionMode: (mode: Parameters[0]) => + impl.setConnectionMode(mode), + getConnectionMode: () => impl.getConnectionMode(), + isOffline: () => impl.isOffline(), + }; + + impl.registerPluginsWith(client); + + return client; +} diff --git a/packages/sdk/node-client/src/NodeDataManager.ts b/packages/sdk/node-client/src/NodeDataManager.ts new file mode 100644 index 0000000000..3bf6a4d856 --- /dev/null +++ b/packages/sdk/node-client/src/NodeDataManager.ts @@ -0,0 +1,203 @@ +import { + BaseDataManager, + Configuration, + ConnectionMode, + Context, + DataSourcePaths, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + makeRequestor, + Platform, + readFlagsFromBootstrap, +} from '@launchdarkly/js-client-sdk-common'; + +import type { ValidatedOptions } from './options'; + +const logTag = '[NodeDataManager]'; + +export default class NodeDataManager extends BaseDataManager { + protected networkAvailable: boolean = true; + protected connectionMode: ConnectionMode = 'streaming'; + + constructor( + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + private readonly _nodeConfig: ValidatedOptions, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) { + super( + platform, + flagManager, + credential, + config, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ); + this.connectionMode = _nodeConfig.initialConnectionMode; + } + + private _debugLog(message: any, ...args: any[]) { + this.logger.debug(`${logTag} ${message}`, ...args); + } + + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + if (this.closed) { + this._debugLog('Identify called after data manager was closed.'); + return; + } + this.context = context; + + if (identifyOptions?.bootstrap) { + this._finishIdentifyFromBootstrap(context, identifyOptions, identifyResolve); + } + const resolvedFromBootstrap = !!identifyOptions?.bootstrap; + + const offline = this.connectionMode === 'offline'; + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; + + const loadedFromCache = await this.flagManager.loadCached(context); + if (loadedFromCache && !waitForNetworkResults && !resolvedFromBootstrap) { + this._debugLog('Identify completing with cached flags'); + identifyResolve(); + } + if (loadedFromCache && waitForNetworkResults) { + this._debugLog( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + } + + if (this.connectionMode === 'offline') { + if (loadedFromCache) { + this._debugLog('Offline identify - using cached flags.'); + } else { + this._debugLog( + 'Offline identify - no cached flags, using defaults or already loaded flags.', + ); + if (!resolvedFromBootstrap) { + identifyResolve(); + } + } + } else { + this._setupConnection(context, identifyResolve, identifyReject); + } + } + + private _finishIdentifyFromBootstrap( + context: Context, + identifyOpts: LDIdentifyOptions, + identifyResolve: () => void, + ): void { + let { bootstrapParsed } = identifyOpts; + if (!bootstrapParsed) { + bootstrapParsed = readFlagsFromBootstrap(this.logger, identifyOpts.bootstrap); + } + this.flagManager.setBootstrap(context, bootstrapParsed); + this._debugLog('Identify - Initialization completed from bootstrap'); + + identifyResolve(); + } + + private _setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + const plainContextString = JSON.stringify(rawContext); + const requestor = makeRequestor( + plainContextString, + this.config.serviceEndpoints, + this.getPollingPaths(), + this.platform.requests, + this.platform.encoding!, + this.baseHeaders, + [], + this.config.withReasons, + this.config.useReport, + this._nodeConfig.hash, + ); + + this.updateProcessor?.close(); + switch (this.connectionMode) { + case 'streaming': + this.createStreamingProcessor( + rawContext, + context, + requestor, + identifyResolve, + identifyReject, + ); + break; + case 'polling': + this.createPollingProcessor( + rawContext, + context, + requestor, + identifyResolve, + identifyReject, + ); + break; + default: + break; + } + this.updateProcessor!.start(); + } + + setNetworkAvailability(available: boolean): void { + this.networkAvailable = available; + } + + async setConnectionMode(mode: ConnectionMode): Promise { + if (this.closed) { + this._debugLog('setting connection mode after data manager was closed'); + return; + } + + if (this.connectionMode === mode) { + this._debugLog(`setConnectionMode ignored. Mode is already '${mode}'.`); + return; + } + + this.connectionMode = mode; + this._debugLog(`setConnectionMode ${mode}.`); + + switch (mode) { + case 'offline': + this.updateProcessor?.close(); + break; + case 'polling': + case 'streaming': + if (this.context) { + this._setupConnection(this.context); + } + break; + default: + this.logger.warn( + `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, + ); + break; + } + } + + getConnectionMode(): ConnectionMode { + return this.connectionMode; + } +} diff --git a/packages/sdk/node-client/src/NodeOptions.ts b/packages/sdk/node-client/src/NodeOptions.ts index 39efba4950..4637b2bcda 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,28 @@ 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. + * + * Plugin support is currently experimental and subject to change. + */ + 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/index.ts b/packages/sdk/node-client/src/index.ts index 0f13b79dda..e9c5a70e56 100644 --- a/packages/sdk/node-client/src/index.ts +++ b/packages/sdk/node-client/src/index.ts @@ -1,6 +1,51 @@ -// Placeholder entry point for @launchdarkly/node-client-sdk. The functional -// implementation (createClient, basicLogger, type re-exports) is added in -// the source-port slice (SDK-2312); this file exists so the package builds -// and the version stamp is in place. +/** + * This is the API reference for the LaunchDarkly Client-Side SDK for Node.js. + * + * In typical usage you will call {@link createClient} once at startup time to obtain an + * instance of {@link LDClient}, then call `client.start()` to begin initialization. + * + * @packageDocumentation + */ +import type { LDContext } from '@launchdarkly/js-client-sdk-common'; + +import basicLogger from './basicLogger'; +import type { LDClient, LDStartOptions } from './LDClient'; +import type { LDPlugin } from './LDPlugin'; +import { makeClient } from './NodeClient'; +import type { LDTLSOptions, NodeOptions } from './NodeOptions'; + +export * from './LDCommon'; + +/** @internal */ +export { resetNodeStorage } from './platform/NodeStorage'; + +export type { + NodeOptions as LDOptions, + LDClient, + LDPlugin, + LDStartOptions, + LDTLSOptions, +}; + +export { basicLogger }; export const version = '0.0.1'; // x-release-please-version + +/** + * Creates a LaunchDarkly client. The client is not ready until {@link LDClient.start} + * is called -- after which the first identify with `initialContext` runs and the returned + * promise resolves. + * + * @param envKey The LaunchDarkly client-side ID for the environment. + * @param initialContext The context used for the first identify on `start()`. + * @param options Optional configuration. + * @returns The client instance. Call `client.start()` before using variations or calling + * `identify()` for context changes. + */ +export function createClient( + envKey: string, + initialContext: LDContext, + options: NodeOptions = {}, +): LDClient { + return makeClient(envKey, initialContext, options); +} diff --git a/packages/sdk/node-client/src/options.ts b/packages/sdk/node-client/src/options.ts new file mode 100644 index 0000000000..5db5303e19 --- /dev/null +++ b/packages/sdk/node-client/src/options.ts @@ -0,0 +1,76 @@ +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, +}; + +const validators: { [Property in keyof NodeOptions]: TypeValidator | undefined } = { + 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 NodeOptions, 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)); + } + } + }); + + return output; +} diff --git a/packages/sdk/node-client/temp_docs/MIGRATION.md b/packages/sdk/node-client/temp_docs/MIGRATION.md new file mode 100644 index 0000000000..d4a2e5e603 --- /dev/null +++ b/packages/sdk/node-client/temp_docs/MIGRATION.md @@ -0,0 +1,242 @@ +# Migrating to this SDK + +Below are some breaking changes between this SDK and the previous +[Node Client-Side SDK](https://github.com/launchdarkly/node-client-sdk) +(`launchdarkly-node-client-sdk`). + +## Package rename + +The package name changed from `launchdarkly-node-client-sdk` to +`@launchdarkly/node-client-sdk`. + +```bash +# before +npm uninstall launchdarkly-node-client-sdk +# after +npm install @launchdarkly/node-client-sdk +``` + +Update any imports: + +```diff +- import { initialize } from 'launchdarkly-node-client-sdk'; ++ import { createClient } from '@launchdarkly/node-client-sdk'; +``` + +## SDK initialization (createClient and start) + +The entry point is now **`createClient`** (replacing `initialize`). Update +any references accordingly. + +- **New signature:** `createClient(envKey, initialContext, options)` -- the + initial context is required as the second argument. + +- **Must call `start()`:** The client is no longer ready when `createClient` + returns. After `createClient()`, the app must call `client.start()` + (optionally with `LDStartOptions`: `timeout`, `bootstrap`, + `identifyOptions`). The promise returned by `start()` resolves when the + first identify completes (or times out, or fails). + +- **No `identify()` before `start()`:** Calling `identify()` before + `start()` is an error (logged and rejected). Use `identify()` only after + `start()` has been called, for subsequent context changes. + +Example: + +```typescript +const client = createClient(clientSideId, initialContext, options); +await client.start(); +// Later, when changing context: +await client.identify(newContext); +``` + +## Identify flow (identify returns result, does not throw) + +`identify()` now returns a promise that **always resolves** to an +`LDIdentifyResult` object. It does **not** throw; success or failure is +indicated by the resolved value. + +- **Return type:** `Promise` +- **Result statuses:** + - `{ status: 'completed' }` -- identification succeeded. + - `{ status: 'error', error: Error }` -- identification failed. + - `{ status: 'timeout', timeout: number }` -- identification did not + complete within the configured timeout. + - `{ status: 'shed' }` -- the identify was shed (e.g. when using + `sheddable: true` and a newer identify superseded it). + +Before (throwing): + +```typescript +try { + await client.identify(newContext); + // success +} catch (err) { + // handle error or timeout +} +``` + +After (result object): + +```typescript +const result = await client.identify(newContext); +if (result.status === 'completed') { + // success +} else if (result.status === 'error') { + // result.error +} else if (result.status === 'timeout') { + // result.timeout (seconds) +} +``` + +You can still `await client.identify(context)` without inspecting the +result if you do not need to handle errors or timeouts explicitly. + +## Evaluation, identify, and track hooks + +The SDK now supports lifecycle hooks. A hook can attach to one or more +stages: `beforeEvaluation` / `afterEvaluation` (variation calls), +`beforeIdentify` / `afterIdentify` (`identify()` calls), and +`afterTrack` (`track()` calls). Hooks can carry state from a before +stage into the matching after stage. + +```typescript +import type { Hook } from '@launchdarkly/node-client-sdk'; + +const timingHook: Hook = { + getMetadata: () => ({ name: 'timing' }), + beforeEvaluation: (ctx, data) => ({ ...data, start: Date.now() }), + afterEvaluation: (ctx, data, detail) => { + metrics.recordVariationLatency(ctx.flagKey, Date.now() - data.start); + return data; + }, +}; + +const client = createClient(envKey, initialContext, { + hooks: [timingHook], +}); +``` + +Hooks can also be registered after `createClient` via `client.addHook(hook)`. + +## Inspectors + +Inspectors are a lower-level monitoring mechanism for flag usage and flag +state changes. They are now exposed through the `inspectors` option. + +```typescript +const client = createClient(envKey, initialContext, { + inspectors: [ + { + type: 'flag-used', + name: 'log-evaluations', + method: (flagKey, detail, context) => { + console.log(`flag ${flagKey} = ${detail.value} for ${context.key}`); + }, + }, + ], +}); +``` + +Inspectors are deprecated and will be removed in a future release; new +code should use hooks instead. Inspectors remain supported for now to +ease migration for consumers that already use them. + +## Plugins and application metadata + +The SDK supports a plugin extension surface. Plugins receive a metadata +object describing the SDK and the host application, including the optional +`applicationInfo`. + +```typescript +import type { LDPlugin } from '@launchdarkly/node-client-sdk'; + +const tracingPlugin: LDPlugin = { + getMetadata: () => ({ name: 'tracing' }), + register: (client, environmentMetadata) => { + // wire up tracing using environmentMetadata.sdk + environmentMetadata.application + }, +}; + +const client = createClient(envKey, initialContext, { + applicationInfo: { + id: 'my-app', + version: '1.2.3', + }, + plugins: [tracingPlugin], +}); +``` + +Plugin support is experimental and subject to change. + +## Runtime connection-mode control + +The SDK starts in the mode specified by `initialConnectionMode` +(default: `streaming`). The mode can be changed at runtime via +`setConnectionMode`, and the current mode read back via +`getConnectionMode`. Valid modes are `offline`, `streaming`, and +`polling`. + +```typescript +const client = createClient(envKey, initialContext, { + initialConnectionMode: 'streaming', +}); +await client.start(); + +await client.setConnectionMode('offline'); // disconnect from LD +await client.setConnectionMode('polling'); // resume in polling mode +``` + +The previous SDK had no runtime control over connection mode; it was +configuration-only. + +## TLS configuration + +TLS parameters are now configured via the `tlsParams` option, which +accepts the same fields as Node's `https.request()`. + +```typescript +import * as fs from 'fs'; + +const client = createClient(envKey, initialContext, { + tlsParams: { + ca: fs.readFileSync('/etc/ssl/custom-ca.pem'), + rejectUnauthorized: true, + }, +}); +``` + +Supported fields include `ca`, `cert`, `key`, `pfx`, `passphrase`, +`ciphers`, `rejectUnauthorized`, `secureProtocol`, `servername`, and +`checkServerIdentity`. + +## Minimum Node version + +Node `>=18` is now required. The previous SDK supported Node `>=12`. Native +`fetch` and `crypto.randomUUID` are now used internally so the SDK no +longer depends on `node-fetch` or polyfills. + +## Persistent cache format + +The on-disk cache used by anonymous-key persistence and last-known flag +values changed format. + +- The previous SDK stored entries via `node-localstorage` -- one file per + key inside `/ldclient-user-cache/`. +- This SDK stores all entries in a single `/ldclient-user-cache/ldcache.json` + file with atomic temp-then-rename writes. + +The default location (`/ldclient-user-cache`) is unchanged, but +existing v3 cache data will not be read. The next anonymous identify will +generate a fresh anonymous key; existing flag values will repopulate from +the network on the next sync. + +If you want to clear out the old cache directory before upgrading, delete +the per-key files inside `/ldclient-user-cache/` (or the path you +configured) before starting the new SDK. + +## Removed dependencies + +- `node-localstorage` -- replaced by an in-tree fs-backed implementation. +- `launchdarkly-eventsource` is still used internally; no caller-visible + change. From b279ce975e5b7939abc9288f68835fb224d5b4f3 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 29 May 2026 15:34:37 -0400 Subject: [PATCH 2/7] chore: PR comments --- .../__tests__/NodeClient.bootstrap.test.ts | 125 ++++++++++++++++++ .../node-client/__tests__/NodeClient.test.ts | 98 ++++++++++++++ .../sdk/node-client/__tests__/index.test.ts | 6 - packages/sdk/node-client/src/LDClient.ts | 13 ++ .../sdk/node-client/src/NodeDataManager.ts | 23 +++- packages/sdk/node-client/src/index.ts | 2 - release-please-config.json | 3 +- 7 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts create mode 100644 packages/sdk/node-client/__tests__/NodeClient.test.ts delete mode 100644 packages/sdk/node-client/__tests__/index.test.ts diff --git a/packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts b/packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts new file mode 100644 index 0000000000..52a656f64d --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts @@ -0,0 +1,125 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { createClient } from '../src'; +import { resetNodeStorage } from '../src/platform/NodeStorage'; +import { createMockLogger } from './testHelpers'; + +let tmpRoot: string; +let logger: ReturnType; + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'node-client-bootstrap-test-')); + resetNodeStorage(); + logger = createMockLogger(); +}); + +afterEach(async () => { + resetNodeStorage(); + await fs.rm(tmpRoot, { recursive: true, force: true }); +}); + +const goodBootstrapData = { + 'string-flag': 'is bob', + 'my-boolean-flag': false, + $flagsState: { + 'string-flag': { + variation: 1, + version: 3, + }, + 'my-boolean-flag': { + variation: 1, + version: 11, + }, + }, + $valid: true, +}; + +const bootstrapDataWithReasons = { + json: ['a', 'b', 'c', 'd'], + $flagsState: { + json: { + variation: 1, + version: 3, + reason: { kind: 'OFF' }, + }, + }, + $valid: true, +}; + +it('start with bootstrap data resolves and exposes flags', async () => { + const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + localStoragePath: tmpRoot, + logger, + }); + + const result = await client.start({ bootstrap: goodBootstrapData }); + + expect(result.status).toBe('complete'); + expect(client.stringVariation('string-flag', 'default')).toBe('is bob'); + expect(client.boolVariation('my-boolean-flag', true)).toBe(false); +}); + +it('exposes evaluation reasons from bootstrap data', async () => { + const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + localStoragePath: tmpRoot, + logger, + }); + + await client.start({ bootstrap: bootstrapDataWithReasons }); + + expect(client.jsonVariationDetail('json', undefined)).toEqual({ + reason: { kind: 'OFF' }, + value: ['a', 'b', 'c', 'd'], + variationIndex: 1, + }); +}); + +it('re-identifying with new bootstrap data replaces previous flags', async () => { + const newBootstrapData = { + 'string-flag': 'is alice', + 'my-boolean-flag': true, + $flagsState: { + 'string-flag': { variation: 1, version: 4 }, + 'my-boolean-flag': { variation: 0, version: 12 }, + }, + $valid: true, + }; + + const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + localStoragePath: tmpRoot, + logger, + }); + + await client.start({ bootstrap: goodBootstrapData }); + expect(client.stringVariation('string-flag', 'default')).toBe('is bob'); + + await client.identify({ kind: 'user', key: 'alice' }, { bootstrap: newBootstrapData }); + expect(client.stringVariation('string-flag', 'default')).toBe('is alice'); + expect(client.boolVariation('my-boolean-flag', false)).toBe(true); +}); + +it('returns defaults when no bootstrap data is provided', async () => { + const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + localStoragePath: tmpRoot, + logger, + }); + + await client.start(); + + expect(client.stringVariation('string-flag', 'default')).toBe('default'); + expect(client.boolVariation('my-boolean-flag', true)).toBe(true); +}); diff --git a/packages/sdk/node-client/__tests__/NodeClient.test.ts b/packages/sdk/node-client/__tests__/NodeClient.test.ts new file mode 100644 index 0000000000..e61542ce9c --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.test.ts @@ -0,0 +1,98 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { createClient } from '../src'; +import { resetNodeStorage } from '../src/platform/NodeStorage'; +import { createMockLogger } from './testHelpers'; + +let tmpRoot: string; +let logger: ReturnType; + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'node-client-test-')); + resetNodeStorage(); + logger = createMockLogger(); +}); + +afterEach(async () => { + resetNodeStorage(); + await fs.rm(tmpRoot, { recursive: true, force: true }); +}); + +it('createClient returns the documented LDClient surface', () => { + const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + localStoragePath: tmpRoot, + logger, + }); + + expect(typeof client.start).toBe('function'); + expect(typeof client.identify).toBe('function'); + expect(typeof client.close).toBe('function'); + expect(typeof client.variation).toBe('function'); + expect(typeof client.variationDetail).toBe('function'); + expect(typeof client.boolVariation).toBe('function'); + expect(typeof client.boolVariationDetail).toBe('function'); + expect(typeof client.stringVariation).toBe('function'); + expect(typeof client.stringVariationDetail).toBe('function'); + expect(typeof client.numberVariation).toBe('function'); + expect(typeof client.numberVariationDetail).toBe('function'); + expect(typeof client.jsonVariation).toBe('function'); + expect(typeof client.jsonVariationDetail).toBe('function'); + expect(typeof client.allFlags).toBe('function'); + expect(typeof client.track).toBe('function'); + expect(typeof client.flush).toBe('function'); + expect(typeof client.on).toBe('function'); + expect(typeof client.off).toBe('function'); + expect(typeof client.addHook).toBe('function'); + expect(typeof client.waitForInitialization).toBe('function'); + expect(typeof client.setConnectionMode).toBe('function'); + expect(typeof client.getConnectionMode).toBe('function'); + expect(typeof client.isOffline).toBe('function'); + expect(client.logger).toBeDefined(); +}); + +it('isOffline reflects initialConnectionMode', () => { + const offline = createClient('client-side-id', { kind: 'user', key: 'bob' }, { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + localStoragePath: tmpRoot, + logger, + }); + expect(offline.isOffline()).toBe(true); + expect(offline.getConnectionMode()).toBe('offline'); +}); + +it('setConnectionMode round-trips to offline', async () => { + const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + localStoragePath: tmpRoot, + logger, + }); + + expect(client.getConnectionMode()).toBe('offline'); + expect(client.isOffline()).toBe(true); + + // Setting the same mode is a no-op but should not throw. + await client.setConnectionMode('offline'); + expect(client.getConnectionMode()).toBe('offline'); +}); + +it('start completes in offline mode without performing network identify', async () => { + const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + localStoragePath: tmpRoot, + logger, + }); + + const result = await client.start({ timeout: 5 }); + expect(result.status).toBe('complete'); +}); diff --git a/packages/sdk/node-client/__tests__/index.test.ts b/packages/sdk/node-client/__tests__/index.test.ts deleted file mode 100644 index e183c6cfad..0000000000 --- a/packages/sdk/node-client/__tests__/index.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import * as ld from '../src'; - -it('exports a version string', () => { - expect(typeof ld.version).toBe('string'); - expect(ld.version.length).toBeGreaterThan(0); -}); diff --git a/packages/sdk/node-client/src/LDClient.ts b/packages/sdk/node-client/src/LDClient.ts index 92e7a5cd65..1c7a0fe0ca 100644 --- a/packages/sdk/node-client/src/LDClient.ts +++ b/packages/sdk/node-client/src/LDClient.ts @@ -31,9 +31,22 @@ export interface LDClient extends Omit { */ start(options?: LDStartOptions): Promise; + /** + * Sets the data source connection mode. + * + * Pass `'offline'` to stop the streaming or polling connection and disable analytics event + * delivery. Pass `'streaming'` or `'polling'` to (re)establish the connection using the + * current context. The returned promise resolves once the mode change has been applied. + */ 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/NodeDataManager.ts b/packages/sdk/node-client/src/NodeDataManager.ts index 3bf6a4d856..a760e04afb 100644 --- a/packages/sdk/node-client/src/NodeDataManager.ts +++ b/packages/sdk/node-client/src/NodeDataManager.ts @@ -64,18 +64,20 @@ export default class NodeDataManager extends BaseDataManager { } this.context = context; + let identifyResolved = false; if (identifyOptions?.bootstrap) { this._finishIdentifyFromBootstrap(context, identifyOptions, identifyResolve); + identifyResolved = true; } - const resolvedFromBootstrap = !!identifyOptions?.bootstrap; const offline = this.connectionMode === 'offline'; const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; const loadedFromCache = await this.flagManager.loadCached(context); - if (loadedFromCache && !waitForNetworkResults && !resolvedFromBootstrap) { + if (loadedFromCache && !waitForNetworkResults && !identifyResolved) { this._debugLog('Identify completing with cached flags'); identifyResolve(); + identifyResolved = true; } if (loadedFromCache && waitForNetworkResults) { this._debugLog( @@ -83,17 +85,25 @@ export default class NodeDataManager extends BaseDataManager { ); } - if (this.connectionMode === 'offline') { + if (offline) { if (loadedFromCache) { this._debugLog('Offline identify - using cached flags.'); } else { this._debugLog( 'Offline identify - no cached flags, using defaults or already loaded flags.', ); - if (!resolvedFromBootstrap) { + if (!identifyResolved) { identifyResolve(); } } + return; + } + + // Online path. Pass identify callbacks only if not already resolved -- otherwise + // the streaming/polling processor would receive stale handles and bootstrap/cache + // resolution would race with network resolution. + if (identifyResolved) { + this._setupConnection(context); } else { this._setupConnection(context, identifyResolve, identifyReject); } @@ -156,7 +166,10 @@ export default class NodeDataManager extends BaseDataManager { ); break; default: - break; + this.logger.warn( + `${logTag} _setupConnection called with unsupported connectionMode '${this.connectionMode}'.`, + ); + return; } this.updateProcessor!.start(); } diff --git a/packages/sdk/node-client/src/index.ts b/packages/sdk/node-client/src/index.ts index e9c5a70e56..cd7599287e 100644 --- a/packages/sdk/node-client/src/index.ts +++ b/packages/sdk/node-client/src/index.ts @@ -29,8 +29,6 @@ export type { export { basicLogger }; -export const version = '0.0.1'; // x-release-please-version - /** * Creates a LaunchDarkly client. The client is not ready until {@link LDClient.start} * is called -- after which the first identify with `initialContext` runs and the returned diff --git a/release-please-config.json b/release-please-config.json index d660130121..38b4bde553 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -148,8 +148,7 @@ "packages/sdk/node-client": { "bump-minor-pre-major": true, "extra-files": [ - "src/platform/NodeInfo.ts", - "src/index.ts" + "src/platform/NodeInfo.ts" ] }, "packages/sdk/server-node": { From a4455104dc9edf6c84e27e5c90d0bc20d5bbb2d8 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 29 May 2026 16:46:37 -0400 Subject: [PATCH 3/7] test: adding more unit tests --- .../__tests__/NodeClient.dataSource.test.ts | 309 ++++++++++++++++++ .../__tests__/NodeClient.events.test.ts | 168 ++++++++++ .../node-client/__tests__/NodeClient.mocks.ts | 111 +++++++ .../node-client/__tests__/NodeClient.test.ts | 1 + .../sdk/node-client/src/NodeDataManager.ts | 8 - .../sdk/node-client/temp_docs/MIGRATION.md | 242 -------------- 6 files changed, 589 insertions(+), 250 deletions(-) create mode 100644 packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts create mode 100644 packages/sdk/node-client/__tests__/NodeClient.events.test.ts create mode 100644 packages/sdk/node-client/__tests__/NodeClient.mocks.ts delete mode 100644 packages/sdk/node-client/temp_docs/MIGRATION.md diff --git a/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts b/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts new file mode 100644 index 0000000000..f4e6f07982 --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts @@ -0,0 +1,309 @@ +import type { LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import { createClient } from '../src'; +import NodeDataManager from '../src/NodeDataManager'; +import { makeMockPlatform, mockFetch } from './NodeClient.mocks'; + +// Replace NodePlatform's constructor with one that returns the mock platform. Lets us +// inject deterministic fetch / EventSource without touching the real filesystem or network. +jest.mock('../src/platform/NodePlatform', () => { + const { makeMockPlatform: makePlatform } = jest.requireActual('./NodeClient.mocks'); + return { + __esModule: true, + default: jest.fn().mockImplementation(() => makePlatform()), + }; +}); + +const NodePlatformMock = jest.requireMock('../src/platform/NodePlatform').default as jest.Mock; + +const bootstrapData = { + 'string-flag': 'is bob', + 'my-boolean-flag': false, + $flagsState: { + 'string-flag': { variation: 1, version: 3 }, + 'my-boolean-flag': { variation: 1, version: 11 }, + }, + $valid: true, +}; + +let logger: LDLogger; + +beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + NodePlatformMock.mockReset(); + NodePlatformMock.mockImplementation(() => makeMockPlatform()); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('start with streaming + bootstrap resolves and opens streaming connection', async () => { + const fakePlatform = makeMockPlatform(); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + const result = await client.start({ bootstrap: bootstrapData }); + + expect(result.status).toBe('complete'); + expect(client.stringVariation('string-flag', 'default')).toBe('is bob'); + expect(client.boolVariation('my-boolean-flag', true)).toBe(false); + // Streaming connection was opened for ongoing updates (the fix lets streaming start + // alongside bootstrap; the previous bug just routed identify callbacks through it). + expect(fakePlatform.requests.createEventSource).toHaveBeenCalled(); + + await client.close(); +}); + +it('bootstrap in streaming mode invokes _setupConnection without identify callbacks (regression guard)', async () => { + const setupSpy = jest.spyOn(NodeDataManager.prototype as any, '_setupConnection'); + const fakePlatform = makeMockPlatform(); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + + // The fix: streaming setup happens without forwarding identify callbacks, since + // bootstrap already resolved identify. + expect(setupSpy).toHaveBeenCalled(); + const lastCallArgs = setupSpy.mock.calls[setupSpy.mock.calls.length - 1]; + expect(lastCallArgs[1]).toBeUndefined(); + expect(lastCallArgs[2]).toBeUndefined(); + + await client.close(); +}); + +it('polling mode without bootstrap uses identify callbacks on _setupConnection', async () => { + const setupSpy = jest.spyOn(NodeDataManager.prototype as any, '_setupConnection'); + const fakePlatform = makeMockPlatform({ + requests: { + fetch: mockFetch(JSON.stringify(bootstrapData), 200), + createEventSource: jest.fn(), + getEventSourceCapabilities: () => ({ readTimeout: false, headers: true, customMethod: false }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'polling', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ timeout: 2 }); + + // Without bootstrap, identify is resolved via the network processor -- callbacks + // must be forwarded to _setupConnection. + expect(setupSpy).toHaveBeenCalled(); + const firstCallArgs = setupSpy.mock.calls[0]; + expect(typeof firstCallArgs[1]).toBe('function'); + expect(typeof firstCallArgs[2]).toBe('function'); + + await client.close(); +}); + +it('polling mode opens a fetch request to the polling endpoint', async () => { + const fetchMock = mockFetch(JSON.stringify(bootstrapData), 200); + const fakePlatform = makeMockPlatform({ + requests: { + fetch: fetchMock, + createEventSource: jest.fn(), + getEventSourceCapabilities: () => ({ readTimeout: false, headers: true, customMethod: false }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'polling', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ timeout: 2 }); + + const pollingCall = fetchMock.mock.calls.find(([url]: [string]) => url.includes('/sdk/evalx/')); + expect(pollingCall).toBeDefined(); + + await client.close(); +}); + +it('streaming mode opens an EventSource to the streaming endpoint with authorization header', async () => { + const createEventSource = jest.fn(() => ({ + addEventListener: jest.fn(), + close: jest.fn(), + onclose: jest.fn(), + onerror: jest.fn(), + onopen: jest.fn(), + onretrying: jest.fn(), + })); + const fakePlatform = makeMockPlatform({ + requests: { + fetch: jest.fn(), + createEventSource: createEventSource as any, + getEventSourceCapabilities: () => ({ readTimeout: false, headers: true, customMethod: false }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + + expect(createEventSource).toHaveBeenCalled(); + const firstCall = (createEventSource.mock.calls as unknown as [string, any][])[0]; + expect(firstCall[0]).toMatch(/\/eval\//); + expect(firstCall[1].headers).toMatchObject({ authorization: 'client-side-id' }); + + await client.close(); +}); + +it('setConnectionMode offline -> streaming brings the data source back up', async () => { + const fakePlatform = makeMockPlatform(); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + expect(client.isOffline()).toBe(true); + expect(fakePlatform.requests.createEventSource).not.toHaveBeenCalled(); + + await client.setConnectionMode('streaming'); + expect(client.isOffline()).toBe(false); + expect(client.getConnectionMode()).toBe('streaming'); + expect(fakePlatform.requests.createEventSource).toHaveBeenCalled(); + + await client.close(); +}); + +it('streaming with useReport opens an EventSource using REPORT to the no-context path', async () => { + const createEventSource = jest.fn(() => ({ + addEventListener: jest.fn(), + close: jest.fn(), + onclose: jest.fn(), + onerror: jest.fn(), + onopen: jest.fn(), + onretrying: jest.fn(), + })); + const fakePlatform = makeMockPlatform({ + requests: { + fetch: jest.fn(), + createEventSource: createEventSource as any, + getEventSourceCapabilities: () => ({ readTimeout: true, headers: true, customMethod: true }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: false, + diagnosticOptOut: true, + useReport: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + + expect(createEventSource).toHaveBeenCalled(); + const firstCall = (createEventSource.mock.calls as unknown as [string, any][])[0]; + // REPORT mode hits /eval/ without an encoded context segment in the path. + expect(firstCall[0]).toMatch(/\/eval\/client-side-id(?:\?|$)/); + + await client.close(); +}); + +it('setConnectionMode streaming -> offline tears down the EventSource', async () => { + const eventSourceClose = jest.fn(); + const createEventSource = jest.fn(() => ({ + addEventListener: jest.fn(), + close: eventSourceClose, + onclose: jest.fn(), + onerror: jest.fn(), + onopen: jest.fn(), + onretrying: jest.fn(), + })); + const fakePlatform = makeMockPlatform({ + requests: { + fetch: jest.fn(), + createEventSource: createEventSource as any, + getEventSourceCapabilities: () => ({ readTimeout: false, headers: true, customMethod: false }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + expect(createEventSource).toHaveBeenCalledTimes(1); + + await client.setConnectionMode('offline'); + expect(client.isOffline()).toBe(true); + expect(eventSourceClose).toHaveBeenCalled(); + + await client.close(); +}); diff --git a/packages/sdk/node-client/__tests__/NodeClient.events.test.ts b/packages/sdk/node-client/__tests__/NodeClient.events.test.ts new file mode 100644 index 0000000000..dd21835770 --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.events.test.ts @@ -0,0 +1,168 @@ +import type { LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import { createClient } from '../src'; +import { makeMockPlatform, mockFetch } from './NodeClient.mocks'; + +jest.mock('../src/platform/NodePlatform', () => { + const { makeMockPlatform: makePlatform } = jest.requireActual('./NodeClient.mocks'); + return { + __esModule: true, + default: jest.fn().mockImplementation(() => makePlatform()), + }; +}); + +const NodePlatformMock = jest.requireMock('../src/platform/NodePlatform').default as jest.Mock; + +const bootstrapData = { + $flagsState: {}, + $valid: true, +}; + +let logger: LDLogger; + +beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + NodePlatformMock.mockReset(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('track() sends a custom event over HTTP after flush', async () => { + const fetchMock = mockFetch('', 202); + const fakePlatform = makeMockPlatform({ + requests: { + fetch: fetchMock, + createEventSource: jest.fn(), + getEventSourceCapabilities: () => ({ readTimeout: true, headers: true, customMethod: true }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'offline', + sendEvents: true, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + client.track('eventkey', { thing: 'stuff' }, 42); + await client.flush(); + + const analyticsCall = fetchMock.mock.calls.find(([url]: [string]) => url.includes('/events/bulk/')); + expect(analyticsCall).toBeDefined(); + + const body = JSON.parse(analyticsCall![1].body); + const customEvent = body.find((e: any) => e.kind === 'custom'); + expect(customEvent).toMatchObject({ + kind: 'custom', + key: 'eventkey', + data: { thing: 'stuff' }, + metricValue: 42, + context: { kind: 'user', key: 'bob' }, + }); + + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + + await client.close(); +}); + +it('sends a diagnostic init event when diagnostics are not opted out', async () => { + const fetchMock = mockFetch('', 202); + const fakePlatform = makeMockPlatform({ + requests: { + fetch: fetchMock, + // Stub EventSource -- the streaming connection is opened but we don't drive it. + createEventSource: jest.fn(() => ({ + addEventListener: jest.fn(), + close: jest.fn(), + onclose: jest.fn(), + onerror: jest.fn(), + onopen: jest.fn(), + onretrying: jest.fn(), + })), + getEventSourceCapabilities: () => ({ readTimeout: true, headers: true, customMethod: true }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + // Streaming (not offline) so the EventProcessor starts, which is what triggers the + // diagnostic init event. Bootstrap keeps identify from waiting on the stream. + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: true, + diagnosticOptOut: false, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + await client.flush(); + + const diagnosticCall = fetchMock.mock.calls.find(([url]: [string]) => + url.includes('/events/diagnostic/'), + ); + expect(diagnosticCall).toBeDefined(); + + const body = JSON.parse(diagnosticCall![1].body); + expect(body.kind).toBe('diagnostic-init'); + expect(body.platform).toMatchObject({ name: 'Node' }); + expect(body.sdk).toMatchObject({ name: 'node-client-sdk' }); + + await client.close(); +}); + +it('includes authorization and user-agent headers on the events request', async () => { + const fetchMock = mockFetch('', 202); + const fakePlatform = makeMockPlatform({ + requests: { + fetch: fetchMock, + createEventSource: jest.fn(), + getEventSourceCapabilities: () => ({ readTimeout: true, headers: true, customMethod: true }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'offline', + sendEvents: true, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + client.track('hello'); + await client.flush(); + + const analyticsCall = fetchMock.mock.calls.find(([url]: [string]) => url.includes('/events/bulk/')); + expect(analyticsCall).toBeDefined(); + + const headers = analyticsCall![1].headers; + expect(headers).toMatchObject({ + authorization: 'client-side-id', + }); + // The SDK user-agent header is keyed off NodeInfo.sdkData().userAgentBase. The mocked + // platform reports 'NodeClient', so the header value should start with that prefix. + expect(headers['user-agent']).toMatch(/^NodeClient\//); + + await client.close(); +}); diff --git a/packages/sdk/node-client/__tests__/NodeClient.mocks.ts b/packages/sdk/node-client/__tests__/NodeClient.mocks.ts new file mode 100644 index 0000000000..58fddf6755 --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.mocks.ts @@ -0,0 +1,111 @@ +import type { + EventSource, + EventSourceCapabilities, + EventSourceInitDict, + Platform, + PlatformData, + Requests, + Response, + SdkData, +} from '@launchdarkly/js-client-sdk-common'; + +function mockResponse(value: string, statusCode: number): Promise { + const response: Response = { + headers: { + get: jest.fn(() => null), + keys: jest.fn(), + values: jest.fn(), + entries: jest.fn(), + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +export function mockFetch(value: string, statusCode: number = 200): jest.Mock { + const f = jest.fn(); + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +export interface MockEventSource extends EventSource { + streamUri?: string; + options?: EventSourceInitDict; +} + +export function makeMockEventSource(streamUri: string = '', options?: EventSourceInitDict): MockEventSource { + return { + streamUri, + options, + addEventListener: jest.fn(), + close: jest.fn(), + onclose: jest.fn(), + onerror: jest.fn(), + onopen: jest.fn(), + onretrying: jest.fn(), + } as unknown as MockEventSource; +} + +export function makeMockRequests(): Requests { + return { + fetch: mockFetch('{"flagA": true}', 200), + createEventSource: jest.fn((streamUri: string, options: EventSourceInitDict) => + makeMockEventSource(streamUri, options), + ), + getEventSourceCapabilities: (): EventSourceCapabilities => ({ + readTimeout: false, + headers: true, + customMethod: false, + }), + }; +} + +export interface MockPlatformOptions { + wrapperName?: string; + wrapperVersion?: string; + requests?: Requests; +} + +export function makeMockPlatform(options: MockPlatformOptions = {}): Platform { + const requests = options.requests ?? makeMockRequests(); + return { + requests, + info: { + platformData(): PlatformData { + return { name: 'Node' }; + }, + sdkData(): SdkData { + const sdkData: SdkData = { + name: 'node-client-sdk', + version: '0.0.1', + userAgentBase: 'NodeClient', + }; + if (options.wrapperName) { + sdkData.wrapperName = options.wrapperName; + } + if (options.wrapperVersion) { + sdkData.wrapperVersion = options.wrapperVersion; + } + return sdkData; + }, + }, + crypto: { + createHash: () => ({ + update: () => ({ digest: () => 'mock-digest' }), + digest: () => 'mock-digest', + }), + randomUUID: () => 'mock-uuid', + }, + storage: { + get: jest.fn(async (_key: string) => null), + set: jest.fn(async (_key: string, _value: string) => {}), + clear: jest.fn(async (_key: string) => {}), + }, + encoding: { + btoa: (str: string) => Buffer.from(str).toString('base64'), + }, + } as unknown as Platform; +} diff --git a/packages/sdk/node-client/__tests__/NodeClient.test.ts b/packages/sdk/node-client/__tests__/NodeClient.test.ts index e61542ce9c..800eb78fa9 100644 --- a/packages/sdk/node-client/__tests__/NodeClient.test.ts +++ b/packages/sdk/node-client/__tests__/NodeClient.test.ts @@ -52,6 +52,7 @@ it('createClient returns the documented LDClient surface', () => { expect(typeof client.setConnectionMode).toBe('function'); expect(typeof client.getConnectionMode).toBe('function'); expect(typeof client.isOffline).toBe('function'); + expect(typeof client.getContext).toBe('function'); expect(client.logger).toBeDefined(); }); diff --git a/packages/sdk/node-client/src/NodeDataManager.ts b/packages/sdk/node-client/src/NodeDataManager.ts index a760e04afb..6fc5f7514a 100644 --- a/packages/sdk/node-client/src/NodeDataManager.ts +++ b/packages/sdk/node-client/src/NodeDataManager.ts @@ -79,11 +79,6 @@ export default class NodeDataManager extends BaseDataManager { identifyResolve(); identifyResolved = true; } - if (loadedFromCache && waitForNetworkResults) { - this._debugLog( - 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', - ); - } if (offline) { if (loadedFromCache) { @@ -99,9 +94,6 @@ export default class NodeDataManager extends BaseDataManager { return; } - // Online path. Pass identify callbacks only if not already resolved -- otherwise - // the streaming/polling processor would receive stale handles and bootstrap/cache - // resolution would race with network resolution. if (identifyResolved) { this._setupConnection(context); } else { diff --git a/packages/sdk/node-client/temp_docs/MIGRATION.md b/packages/sdk/node-client/temp_docs/MIGRATION.md deleted file mode 100644 index d4a2e5e603..0000000000 --- a/packages/sdk/node-client/temp_docs/MIGRATION.md +++ /dev/null @@ -1,242 +0,0 @@ -# Migrating to this SDK - -Below are some breaking changes between this SDK and the previous -[Node Client-Side SDK](https://github.com/launchdarkly/node-client-sdk) -(`launchdarkly-node-client-sdk`). - -## Package rename - -The package name changed from `launchdarkly-node-client-sdk` to -`@launchdarkly/node-client-sdk`. - -```bash -# before -npm uninstall launchdarkly-node-client-sdk -# after -npm install @launchdarkly/node-client-sdk -``` - -Update any imports: - -```diff -- import { initialize } from 'launchdarkly-node-client-sdk'; -+ import { createClient } from '@launchdarkly/node-client-sdk'; -``` - -## SDK initialization (createClient and start) - -The entry point is now **`createClient`** (replacing `initialize`). Update -any references accordingly. - -- **New signature:** `createClient(envKey, initialContext, options)` -- the - initial context is required as the second argument. - -- **Must call `start()`:** The client is no longer ready when `createClient` - returns. After `createClient()`, the app must call `client.start()` - (optionally with `LDStartOptions`: `timeout`, `bootstrap`, - `identifyOptions`). The promise returned by `start()` resolves when the - first identify completes (or times out, or fails). - -- **No `identify()` before `start()`:** Calling `identify()` before - `start()` is an error (logged and rejected). Use `identify()` only after - `start()` has been called, for subsequent context changes. - -Example: - -```typescript -const client = createClient(clientSideId, initialContext, options); -await client.start(); -// Later, when changing context: -await client.identify(newContext); -``` - -## Identify flow (identify returns result, does not throw) - -`identify()` now returns a promise that **always resolves** to an -`LDIdentifyResult` object. It does **not** throw; success or failure is -indicated by the resolved value. - -- **Return type:** `Promise` -- **Result statuses:** - - `{ status: 'completed' }` -- identification succeeded. - - `{ status: 'error', error: Error }` -- identification failed. - - `{ status: 'timeout', timeout: number }` -- identification did not - complete within the configured timeout. - - `{ status: 'shed' }` -- the identify was shed (e.g. when using - `sheddable: true` and a newer identify superseded it). - -Before (throwing): - -```typescript -try { - await client.identify(newContext); - // success -} catch (err) { - // handle error or timeout -} -``` - -After (result object): - -```typescript -const result = await client.identify(newContext); -if (result.status === 'completed') { - // success -} else if (result.status === 'error') { - // result.error -} else if (result.status === 'timeout') { - // result.timeout (seconds) -} -``` - -You can still `await client.identify(context)` without inspecting the -result if you do not need to handle errors or timeouts explicitly. - -## Evaluation, identify, and track hooks - -The SDK now supports lifecycle hooks. A hook can attach to one or more -stages: `beforeEvaluation` / `afterEvaluation` (variation calls), -`beforeIdentify` / `afterIdentify` (`identify()` calls), and -`afterTrack` (`track()` calls). Hooks can carry state from a before -stage into the matching after stage. - -```typescript -import type { Hook } from '@launchdarkly/node-client-sdk'; - -const timingHook: Hook = { - getMetadata: () => ({ name: 'timing' }), - beforeEvaluation: (ctx, data) => ({ ...data, start: Date.now() }), - afterEvaluation: (ctx, data, detail) => { - metrics.recordVariationLatency(ctx.flagKey, Date.now() - data.start); - return data; - }, -}; - -const client = createClient(envKey, initialContext, { - hooks: [timingHook], -}); -``` - -Hooks can also be registered after `createClient` via `client.addHook(hook)`. - -## Inspectors - -Inspectors are a lower-level monitoring mechanism for flag usage and flag -state changes. They are now exposed through the `inspectors` option. - -```typescript -const client = createClient(envKey, initialContext, { - inspectors: [ - { - type: 'flag-used', - name: 'log-evaluations', - method: (flagKey, detail, context) => { - console.log(`flag ${flagKey} = ${detail.value} for ${context.key}`); - }, - }, - ], -}); -``` - -Inspectors are deprecated and will be removed in a future release; new -code should use hooks instead. Inspectors remain supported for now to -ease migration for consumers that already use them. - -## Plugins and application metadata - -The SDK supports a plugin extension surface. Plugins receive a metadata -object describing the SDK and the host application, including the optional -`applicationInfo`. - -```typescript -import type { LDPlugin } from '@launchdarkly/node-client-sdk'; - -const tracingPlugin: LDPlugin = { - getMetadata: () => ({ name: 'tracing' }), - register: (client, environmentMetadata) => { - // wire up tracing using environmentMetadata.sdk + environmentMetadata.application - }, -}; - -const client = createClient(envKey, initialContext, { - applicationInfo: { - id: 'my-app', - version: '1.2.3', - }, - plugins: [tracingPlugin], -}); -``` - -Plugin support is experimental and subject to change. - -## Runtime connection-mode control - -The SDK starts in the mode specified by `initialConnectionMode` -(default: `streaming`). The mode can be changed at runtime via -`setConnectionMode`, and the current mode read back via -`getConnectionMode`. Valid modes are `offline`, `streaming`, and -`polling`. - -```typescript -const client = createClient(envKey, initialContext, { - initialConnectionMode: 'streaming', -}); -await client.start(); - -await client.setConnectionMode('offline'); // disconnect from LD -await client.setConnectionMode('polling'); // resume in polling mode -``` - -The previous SDK had no runtime control over connection mode; it was -configuration-only. - -## TLS configuration - -TLS parameters are now configured via the `tlsParams` option, which -accepts the same fields as Node's `https.request()`. - -```typescript -import * as fs from 'fs'; - -const client = createClient(envKey, initialContext, { - tlsParams: { - ca: fs.readFileSync('/etc/ssl/custom-ca.pem'), - rejectUnauthorized: true, - }, -}); -``` - -Supported fields include `ca`, `cert`, `key`, `pfx`, `passphrase`, -`ciphers`, `rejectUnauthorized`, `secureProtocol`, `servername`, and -`checkServerIdentity`. - -## Minimum Node version - -Node `>=18` is now required. The previous SDK supported Node `>=12`. Native -`fetch` and `crypto.randomUUID` are now used internally so the SDK no -longer depends on `node-fetch` or polyfills. - -## Persistent cache format - -The on-disk cache used by anonymous-key persistence and last-known flag -values changed format. - -- The previous SDK stored entries via `node-localstorage` -- one file per - key inside `/ldclient-user-cache/`. -- This SDK stores all entries in a single `/ldclient-user-cache/ldcache.json` - file with atomic temp-then-rename writes. - -The default location (`/ldclient-user-cache`) is unchanged, but -existing v3 cache data will not be read. The next anonymous identify will -generate a fresh anonymous key; existing flag values will repopulate from -the network on the next sync. - -If you want to clear out the old cache directory before upgrading, delete -the per-key files inside `/ldclient-user-cache/` (or the path you -configured) before starting the new SDK. - -## Removed dependencies - -- `node-localstorage` -- replaced by an in-tree fs-backed implementation. -- `launchdarkly-eventsource` is still used internally; no caller-visible - change. From 01a2a599bdf04227f3ef3001cebd8f1d5eae44c3 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 1 Jun 2026 11:04:24 -0400 Subject: [PATCH 4/7] chore: pr comments --- .../__tests__/NodeClient.bootstrap.test.ts | 19 ++++ .../__tests__/NodeClient.dataSource.test.ts | 99 +++++++++++++++++++ .../__tests__/NodeClient.events.test.ts | 60 +++++++++++ packages/sdk/node-client/src/NodeClient.ts | 32 ++++-- .../sdk/node-client/src/NodeDataManager.ts | 43 +++++--- packages/sdk/node-client/src/options.ts | 7 ++ .../node-client/src/platform/NodePlatform.ts | 7 +- .../node-client/src/platform/NodeRequests.ts | 4 +- .../node-client/src/platform/NodeResponse.ts | 11 +++ .../node-client/src/platform/NodeStorage.ts | 45 ++++++++- 10 files changed, 297 insertions(+), 30 deletions(-) diff --git a/packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts b/packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts index 52a656f64d..537d5cc5ec 100644 --- a/packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts +++ b/packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts @@ -109,6 +109,25 @@ it('re-identifying with new bootstrap data replaces previous flags', async () => expect(client.boolVariation('my-boolean-flag', false)).toBe(true); }); +it('warns that waitForNetworkResults is ignored when combined with bootstrap', async () => { + const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, { + initialConnectionMode: 'offline', + sendEvents: false, + diagnosticOptOut: true, + localStoragePath: tmpRoot, + logger, + }); + + await client.start({ bootstrap: goodBootstrapData }); + const result = await client.identify( + { kind: 'user', key: 'alice' }, + { bootstrap: goodBootstrapData, waitForNetworkResults: true }, + ); + + expect(result.status).toBe('completed'); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('waitForNetworkResults')); +}); + it('returns defaults when no bootstrap data is provided', async () => { const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, { initialConnectionMode: 'offline', diff --git a/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts b/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts index f4e6f07982..300e5a9c0e 100644 --- a/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts +++ b/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts @@ -2,6 +2,7 @@ import type { LDLogger } from '@launchdarkly/js-client-sdk-common'; import { createClient } from '../src'; import NodeDataManager from '../src/NodeDataManager'; +import { NodeClient } from '../src/NodeClient'; import { makeMockPlatform, mockFetch } from './NodeClient.mocks'; // Replace NodePlatform's constructor with one that returns the mock platform. Lets us @@ -307,3 +308,101 @@ it('setConnectionMode streaming -> offline tears down the EventSource', async () await client.close(); }); + +it('keeps event-sending state consistent with the mode under concurrent setConnectionMode', async () => { + const fakePlatform = makeMockPlatform({ + requests: { + fetch: mockFetch('', 202), + createEventSource: jest.fn(() => ({ + addEventListener: jest.fn(), + close: jest.fn(), + onclose: jest.fn(), + onerror: jest.fn(), + onopen: jest.fn(), + onretrying: jest.fn(), + })), + getEventSourceCapabilities: () => ({ readTimeout: true, headers: true, customMethod: true }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + // Use the implementation directly so we can assert on the internal event-sending flag, + // which governs background (timer-driven) analytics delivery. + const client = new NodeClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: true, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + + // Fire two transitions without awaiting between them. Without serialization the offline + // transition could settle while event-sending is left enabled. + const p1 = client.setConnectionMode('streaming'); + const p2 = client.setConnectionMode('offline'); + await Promise.all([p1, p2]); + + expect(client.getConnectionMode()).toBe('offline'); + expect(client.isOffline()).toBe(true); + // When offline, background analytics delivery must be disabled. + // eslint-disable-next-line no-underscore-dangle + expect((client as any)._eventSendingEnabled).toBe(false); + + await client.close(); +}); + +it('rejects identify called after close without waiting for the identify timeout', async () => { + const fakePlatform = makeMockPlatform(); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + await client.close(); + + const start = Date.now(); + const result = await client.identify({ kind: 'user', key: 'alice' }); + const elapsed = Date.now() - start; + + expect(result.status).toBe('error'); + // Should fail fast, not sit until the 5s identify timeout. + expect(elapsed).toBeLessThan(1000); +}); + +it('does not read cached flags when bootstrap is provided', async () => { + const fakePlatform = makeMockPlatform(); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + + // Bootstrap and cache are mutually exclusive: cached flags must not be consulted (and so + // cannot overwrite) the freshly applied bootstrap data. + expect(fakePlatform.storage!.get).not.toHaveBeenCalled(); + + await client.close(); +}); diff --git a/packages/sdk/node-client/__tests__/NodeClient.events.test.ts b/packages/sdk/node-client/__tests__/NodeClient.events.test.ts index dd21835770..70a7bf59ab 100644 --- a/packages/sdk/node-client/__tests__/NodeClient.events.test.ts +++ b/packages/sdk/node-client/__tests__/NodeClient.events.test.ts @@ -166,3 +166,63 @@ it('includes authorization and user-agent headers on the events request', async await client.close(); }); + +it('delivers events tracked across an offline transition once back online', async () => { + const fetchMock = mockFetch('', 202); + const fakePlatform = makeMockPlatform({ + requests: { + fetch: fetchMock, + createEventSource: jest.fn(() => ({ + addEventListener: jest.fn(), + close: jest.fn(), + onclose: jest.fn(), + onerror: jest.fn(), + onopen: jest.fn(), + onretrying: jest.fn(), + })), + getEventSourceCapabilities: () => ({ readTimeout: true, headers: true, customMethod: true }), + }, + }); + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: true, + diagnosticOptOut: true, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + + client.track('eventA'); + await client.setConnectionMode('offline'); + client.track('eventB'); + await client.setConnectionMode('streaming'); + client.track('eventC'); + await client.flush(); + + const customKeys = new Set(); + fetchMock.mock.calls + .filter(([url]: [string]) => url.includes('/events/bulk/')) + .forEach((call: any) => { + try { + JSON.parse(call[1].body).forEach((e: any) => { + if (e.kind === 'custom') { + customKeys.add(e.key); + } + }); + } catch { + // not JSON, skip + } + }); + + expect(customKeys.has('eventA')).toBe(true); + expect(customKeys.has('eventB')).toBe(true); + expect(customKeys.has('eventC')).toBe(true); + + await client.close(); +}); diff --git a/packages/sdk/node-client/src/NodeClient.ts b/packages/sdk/node-client/src/NodeClient.ts index 4a6ea177ce..7273b00522 100644 --- a/packages/sdk/node-client/src/NodeClient.ts +++ b/packages/sdk/node-client/src/NodeClient.ts @@ -28,6 +28,10 @@ import NodePlatform from './platform/NodePlatform'; export class NodeClient extends LDClientImpl { private readonly _plugins: LDPlugin[]; + // Serializes connection-mode transitions so concurrent calls cannot leave event-sending + // state out of sync with the active connection mode. + private _connectionModeQueue: Promise = Promise.resolve(); + constructor(envKey: string, initialContext: LDContext, options: NodeOptions = {}) { const { logger: customLogger, debug } = options; const logger = customLogger ?? basicLogger({ level: debug ? 'debug' : 'info' }); @@ -45,7 +49,7 @@ export class NodeClient extends LDClientImpl { initialContext, }; - const platform = new NodePlatform(logger, options); + const platform = new NodePlatform(logger, validatedNodeOptions); const endpoints = browserFdv1Endpoints(envKey); super( @@ -99,14 +103,24 @@ export class NodeClient extends LDClientImpl { } async setConnectionMode(mode: ConnectionMode): Promise { - if (mode === 'offline') { - this.setEventSendingEnabled(false, true); - } - const dataManager = this.dataManager as NodeDataManager; - await dataManager.setConnectionMode(mode); - if (mode !== 'offline') { - this.setEventSendingEnabled(true, false); - } + const task = this._connectionModeQueue.then(async () => { + const dataManager = this.dataManager as NodeDataManager; + if (mode === 'offline') { + // Disable analytics, then drain any queued events before tearing down the data source. + this.setEventSendingEnabled(false, false); + await this.flush(); + } + try { + await dataManager.setConnectionMode(mode); + } finally { + // Read the mode back so event-sending always matches the mode that actually took + // effect, even if the transition failed partway. + this.setEventSendingEnabled(dataManager.getConnectionMode() !== 'offline', false); + } + }); + // Keep the queue alive even if a transition fails; the failure still propagates to this caller. + this._connectionModeQueue = task.catch(() => {}); + return task; } getConnectionMode(): ConnectionMode { diff --git a/packages/sdk/node-client/src/NodeDataManager.ts b/packages/sdk/node-client/src/NodeDataManager.ts index 6fc5f7514a..eed56ce074 100644 --- a/packages/sdk/node-client/src/NodeDataManager.ts +++ b/packages/sdk/node-client/src/NodeDataManager.ts @@ -4,6 +4,7 @@ import { ConnectionMode, Context, DataSourcePaths, + DataSourceState, FlagManager, internal, LDEmitter, @@ -19,7 +20,6 @@ import type { ValidatedOptions } from './options'; const logTag = '[NodeDataManager]'; export default class NodeDataManager extends BaseDataManager { - protected networkAvailable: boolean = true; protected connectionMode: ConnectionMode = 'streaming'; constructor( @@ -60,21 +60,36 @@ export default class NodeDataManager extends BaseDataManager { ): Promise { if (this.closed) { this._debugLog('Identify called after data manager was closed.'); + identifyReject(new Error('Client has been closed.')); return; } this.context = context; - let identifyResolved = false; + const offline = this.connectionMode === 'offline'; + + // Bootstrap and cache are mutually exclusive: when bootstrap data is provided it + // resolves identify immediately, so we must not also load (and potentially overwrite + // with) stale cached flags. if (identifyOptions?.bootstrap) { + if (identifyOptions.waitForNetworkResults) { + this.logger.warn( + `${logTag} 'waitForNetworkResults' is ignored when 'bootstrap' is provided.`, + ); + } this._finishIdentifyFromBootstrap(context, identifyOptions, identifyResolve); - identifyResolved = true; + if (!offline) { + // Open a connection for ongoing updates, but identify is already resolved so no + // callbacks are forwarded. + this._setupConnection(context); + } + return; } - const offline = this.connectionMode === 'offline'; const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; const loadedFromCache = await this.flagManager.loadCached(context); - if (loadedFromCache && !waitForNetworkResults && !identifyResolved) { + let identifyResolved = false; + if (loadedFromCache && !waitForNetworkResults) { this._debugLog('Identify completing with cached flags'); identifyResolve(); identifyResolved = true; @@ -87,9 +102,7 @@ export default class NodeDataManager extends BaseDataManager { this._debugLog( 'Offline identify - no cached flags, using defaults or already loaded flags.', ); - if (!identifyResolved) { - identifyResolve(); - } + identifyResolve(); } return; } @@ -111,6 +124,7 @@ export default class NodeDataManager extends BaseDataManager { bootstrapParsed = readFlagsFromBootstrap(this.logger, identifyOpts.bootstrap); } this.flagManager.setBootstrap(context, bootstrapParsed); + this.dataSourceStatusManager.requestStateUpdate(DataSourceState.Valid); this._debugLog('Identify - Initialization completed from bootstrap'); identifyResolve(); @@ -121,7 +135,12 @@ export default class NodeDataManager extends BaseDataManager { identifyResolve?: () => void, identifyReject?: (err: Error) => void, ) { - const rawContext = Context.toLDContext(context)!; + const rawContext = Context.toLDContext(context); + if (!rawContext) { + this.logger.error(`${logTag} Unable to convert context; cannot establish connection.`); + identifyReject?.(new Error('Invalid context.')); + return; + } const plainContextString = JSON.stringify(rawContext); const requestor = makeRequestor( @@ -161,15 +180,12 @@ export default class NodeDataManager extends BaseDataManager { this.logger.warn( `${logTag} _setupConnection called with unsupported connectionMode '${this.connectionMode}'.`, ); + this.updateProcessor = undefined; return; } this.updateProcessor!.start(); } - setNetworkAvailability(available: boolean): void { - this.networkAvailable = available; - } - async setConnectionMode(mode: ConnectionMode): Promise { if (this.closed) { this._debugLog('setting connection mode after data manager was closed'); @@ -187,6 +203,7 @@ export default class NodeDataManager extends BaseDataManager { switch (mode) { case 'offline': this.updateProcessor?.close(); + this.updateProcessor = undefined; break; case 'polling': case 'streaming': diff --git a/packages/sdk/node-client/src/options.ts b/packages/sdk/node-client/src/options.ts index 5db5303e19..734ac0c807 100644 --- a/packages/sdk/node-client/src/options.ts +++ b/packages/sdk/node-client/src/options.ts @@ -72,5 +72,12 @@ export default function validateOptions(opts: NodeOptions, logger: LDLogger): Va } }); + 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); } diff --git a/packages/sdk/node-client/src/platform/NodeRequests.ts b/packages/sdk/node-client/src/platform/NodeRequests.ts index 1ed819e94d..bdc3633990 100644 --- a/packages/sdk/node-client/src/platform/NodeRequests.ts +++ b/packages/sdk/node-client/src/platform/NodeRequests.ts @@ -13,6 +13,8 @@ import NodeResponse from './NodeResponse'; const gzip = promisify(zlib.gzip); +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + function processTlsOptions(tlsOptions: LDTLSOptions): https.AgentOptions { const options: https.AgentOptions & { [index: string]: any } = { ca: tlsOptions.ca, @@ -76,7 +78,7 @@ export default class NodeRequests implements platform.Requests { const req = impl.request( url, { - timeout: options.timeout, + timeout: options.timeout ?? DEFAULT_REQUEST_TIMEOUT_MS, headers, method: options.method, agent: this._agent, diff --git a/packages/sdk/node-client/src/platform/NodeResponse.ts b/packages/sdk/node-client/src/platform/NodeResponse.ts index 63615bbf8a..52bb134f38 100644 --- a/packages/sdk/node-client/src/platform/NodeResponse.ts +++ b/packages/sdk/node-client/src/platform/NodeResponse.ts @@ -6,14 +6,25 @@ import { platform } from '@launchdarkly/js-client-sdk-common'; import HeaderWrapper from './HeaderWrapper'; +// Upper bound on a buffered response body. Flag and event responses are far smaller than this; +// the cap prevents a misbehaving or hostile endpoint from exhausting memory with a huge body. +const MAX_RESPONSE_BYTES = 100 * 1024 * 1024; + export default class NodeResponse implements platform.Response { incomingMessage: http.IncomingMessage; chunks: any[] = []; + private _totalBytes: number = 0; + memoryStream: Writable = new Writable({ decodeStrings: true, write: (chunk, _enc, next) => { + this._totalBytes += chunk.length; + if (this._totalBytes > MAX_RESPONSE_BYTES) { + next(new Error(`Response body exceeded maximum size of ${MAX_RESPONSE_BYTES} bytes`)); + return; + } this.chunks.push(chunk); next(); }, diff --git a/packages/sdk/node-client/src/platform/NodeStorage.ts b/packages/sdk/node-client/src/platform/NodeStorage.ts index 264e37301d..a4c1ebee8e 100644 --- a/packages/sdk/node-client/src/platform/NodeStorage.ts +++ b/packages/sdk/node-client/src/platform/NodeStorage.ts @@ -46,10 +46,16 @@ export default class NodeStorage implements Storage { try { const data = await fs.readFile(this._storageFile, 'utf8'); const parsed = JSON.parse(data); - if (parsed && typeof parsed === 'object') { - this._cache = new Map(Object.entries(parsed as Record)); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const entries = Object.entries(parsed).filter( + ([, value]) => typeof value === 'string', + ) as [string, string][]; + this._cache = new Map(entries); } - } catch { + } catch (error) { + this._logger?.warn( + `Discarding malformed flag cache at ${this._storageFile}: ${error instanceof Error ? error.message : error}`, + ); await this._atomicWriteToFile(this._cache); } @@ -62,10 +68,26 @@ export default class NodeStorage implements Storage { private async _atomicWriteToFile(data: Map): Promise { const content = JSON.stringify(Object.fromEntries(data)); + let handle: fs.FileHandle | undefined; try { - await fs.writeFile(this._tempFile, content, { encoding: 'utf8', mode: 0o600 }); + try { + await fs.unlink(this._tempFile); + } catch { + // Ignore if temp file does not exist. + } + handle = await fs.open(this._tempFile, 'wx', 0o600); + await handle.writeFile(content, 'utf8'); + await handle.close(); + handle = undefined; await fs.rename(this._tempFile, this._storageFile); } catch (error) { + if (handle) { + try { + await handle.close(); + } catch { + // Ignore close errors during cleanup. + } + } try { await fs.unlink(this._tempFile); } catch { @@ -133,7 +155,13 @@ export default class NodeStorage implements Storage { await this._atomicWriteToFile(new Map(this._cache)); }); - this._flushQueue = flush.catch(() => {}); + // Batched callers chain off _flushQueue; log here so a failed write is never silently + // masked for callers that did not directly await this flush. + this._flushQueue = flush.catch((error) => { + this._logger?.error( + `Storage flush failed: ${error instanceof Error ? error.message : error}`, + ); + }); return flush; } } @@ -142,10 +170,16 @@ export default class NodeStorage implements Storage { // process share the same cache file. The first call's storagePath / logger wins; // later calls ignore the arguments. let instance: NodeStorage | undefined; +let instancePath: string | undefined; export function getNodeStorage(storagePath?: string, logger?: LDLogger): NodeStorage { if (!instance) { instance = new NodeStorage(storagePath, logger); + instancePath = storagePath; + } else if (storagePath !== undefined && storagePath !== instancePath) { + logger?.warn( + `NodeStorage was already initialized with a different localStoragePath; ignoring '${storagePath}'.`, + ); } return instance; } @@ -153,4 +187,5 @@ export function getNodeStorage(storagePath?: string, logger?: LDLogger): NodeSto /** @internal Visible for testing only. */ export function resetNodeStorage(): void { instance = undefined; + instancePath = undefined; } From 027ee32a17a7c55368f0ed48c015b8a4f07b7307 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 1 Jun 2026 12:07:05 -0400 Subject: [PATCH 5/7] chore: pr comments --- .../__tests__/NodeClient.dataSource.test.ts | 61 +++++++++++++++++++ packages/sdk/node-client/src/NodeClient.ts | 10 +-- .../sdk/node-client/src/NodeDataManager.ts | 5 ++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts b/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts index 300e5a9c0e..f5ac11ac41 100644 --- a/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts +++ b/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts @@ -406,3 +406,64 @@ it('does not read cached flags when bootstrap is provided', async () => { await client.close(); }); + +it('rejects identify rather than hanging when the mode flips to offline mid-identify', async () => { + // Gate the cached-flag read so we can flip the connection mode while identify is parked on + // the await -- reproducing the race where _setupConnection later sees connectionMode==='offline'. + let releaseGet: () => void = () => {}; + const getGate = new Promise((resolve) => { + releaseGet = resolve; + }); + + const fakePlatform = makeMockPlatform({ + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(() => ({ + addEventListener: jest.fn(), + close: jest.fn(), + onclose: jest.fn(), + onerror: jest.fn(), + onopen: jest.fn(), + onretrying: jest.fn(), + })), + getEventSourceCapabilities: () => ({ readTimeout: true, headers: true, customMethod: true }), + }, + }); + (fakePlatform as any).storage = { + get: jest.fn(async () => { + await getGate; + return null; + }), + set: jest.fn(async () => {}), + clear: jest.fn(async () => {}), + }; + NodePlatformMock.mockImplementationOnce(() => fakePlatform); + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'bob' }, + { + initialConnectionMode: 'streaming', + sendEvents: false, + diagnosticOptOut: true, + logger, + }, + ); + + // Bootstrap on start so the first identify skips the (gated) cache read. + await client.start({ bootstrap: bootstrapData }); + + // A second identify without bootstrap routes through the cache path and parks on the gated get. + const identifyPromise = client.identify({ kind: 'user', key: 'alice' }, { timeout: 2 }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Flip to offline while identify is parked, then release the cache read. + await client.setConnectionMode('offline'); + releaseGet(); + + const result = await identifyPromise; + // With the fix the identify settles immediately as an error; the bug would hang to timeout. + expect(result.status).toBe('error'); + + await client.close(); +}); diff --git a/packages/sdk/node-client/src/NodeClient.ts b/packages/sdk/node-client/src/NodeClient.ts index 7273b00522..249e19b046 100644 --- a/packages/sdk/node-client/src/NodeClient.ts +++ b/packages/sdk/node-client/src/NodeClient.ts @@ -105,12 +105,12 @@ export class NodeClient extends LDClientImpl { async setConnectionMode(mode: ConnectionMode): Promise { const task = this._connectionModeQueue.then(async () => { const dataManager = this.dataManager as NodeDataManager; - if (mode === 'offline') { - // Disable analytics, then drain any queued events before tearing down the data source. - this.setEventSendingEnabled(false, false); - await this.flush(); - } try { + if (mode === 'offline') { + // Disable analytics, then drain any queued events before tearing down the data source. + this.setEventSendingEnabled(false, false); + await this.flush(); + } await dataManager.setConnectionMode(mode); } finally { // Read the mode back so event-sending always matches the mode that actually took diff --git a/packages/sdk/node-client/src/NodeDataManager.ts b/packages/sdk/node-client/src/NodeDataManager.ts index eed56ce074..393005731b 100644 --- a/packages/sdk/node-client/src/NodeDataManager.ts +++ b/packages/sdk/node-client/src/NodeDataManager.ts @@ -181,6 +181,11 @@ export default class NodeDataManager extends BaseDataManager { `${logTag} _setupConnection called with unsupported connectionMode '${this.connectionMode}'.`, ); this.updateProcessor = undefined; + // The mode may have changed to 'offline' while identify was awaiting the cache; reject + // rather than leave the identify promise to hang until its timeout. + identifyReject?.( + new Error(`Connection mode changed to '${this.connectionMode}' during identify.`), + ); return; } this.updateProcessor!.start(); From 35df26e8d7c9bf039ca6f6914b29d9bffd727ebe Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 1 Jun 2026 12:35:24 -0400 Subject: [PATCH 6/7] chore: daggers config --- .claude/plan.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .claude/plan.json diff --git a/.claude/plan.json b/.claude/plan.json new file mode 100644 index 0000000000..578eebc92f --- /dev/null +++ b/.claude/plan.json @@ -0,0 +1,6 @@ +{ + "holistic_branch": "skz/sdk-2195/node-client-sdk-next-port-client", + "jira_subtasks": false, + "worktrees": false, + "budget": { "target": 400, "ceiling": 800 } +} From ec40d10029ba98eaabc5f4c2682bfdf66f29ff57 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 1 Jun 2026 12:56:00 -0400 Subject: [PATCH 7/7] test: Add coverage for Node platform hardening Pins the new hardening behavior: - NodeStorage: warn on malformed cache, non-string cache values ignored, symlink-safe temp write, localStoragePath-mismatch warning. - NodeResponse: rejects when the body exceeds the size cap (exports MAX_RESPONSE_BYTES so the test references the exact threshold). Co-Authored-By: Claude Opus 4.7 --- .../__tests__/platform/NodeResponse.test.ts | 9 +++- .../__tests__/platform/NodeStorage.test.ts | 53 +++++++++++++++++++ .../node-client/src/platform/NodeResponse.ts | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/sdk/node-client/__tests__/platform/NodeResponse.test.ts b/packages/sdk/node-client/__tests__/platform/NodeResponse.test.ts index fd33d45d2f..004b2d5598 100644 --- a/packages/sdk/node-client/__tests__/platform/NodeResponse.test.ts +++ b/packages/sdk/node-client/__tests__/platform/NodeResponse.test.ts @@ -2,7 +2,7 @@ import * as http from 'http'; import { Readable } from 'stream'; import * as zlib from 'zlib'; -import NodeResponse from '../../src/platform/NodeResponse'; +import NodeResponse, { MAX_RESPONSE_BYTES } from '../../src/platform/NodeResponse'; function makeIncomingMessage( body: Buffer | string, @@ -51,6 +51,13 @@ it('decodes a gzip-encoded body', async () => { await expect(res.text()).resolves.toBe('compressed payload'); }); +it('rejects when the response body exceeds the maximum size', async () => { + // One chunk just over the cap trips the limit on the first write, so nothing is buffered. + const oversized = Buffer.allocUnsafe(MAX_RESPONSE_BYTES + 1); + const res = new NodeResponse(makeIncomingMessage(oversized)); + await expect(res.text()).rejects.toThrow(/exceeded maximum size/); +}); + it('rejects text() when the pipeline encounters an error', async () => { const erroring = new Readable({ read() { diff --git a/packages/sdk/node-client/__tests__/platform/NodeStorage.test.ts b/packages/sdk/node-client/__tests__/platform/NodeStorage.test.ts index 84d34aa693..9364ca27ec 100644 --- a/packages/sdk/node-client/__tests__/platform/NodeStorage.test.ts +++ b/packages/sdk/node-client/__tests__/platform/NodeStorage.test.ts @@ -70,6 +70,48 @@ it('recovers when the storage file contains invalid JSON', async () => { await expect(storage.get('alpha')).resolves.toBe('one'); }); +it('warns when the cache file is not valid JSON', async () => { + await fs.writeFile(path.join(tmpRoot, 'ldcache.json'), 'not json', 'utf8'); + + const logger = createMockLogger(); + const storage = new NodeStorage(tmpRoot, logger); + await expect(storage.get('anything')).resolves.toBeNull(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Discarding malformed flag cache'), + ); +}); + +it('ignores non-string values when loading the cache', async () => { + await fs.writeFile( + path.join(tmpRoot, 'ldcache.json'), + JSON.stringify({ good: 'keep', obj: { nested: true }, arr: [1, 2], num: 5 }), + 'utf8', + ); + + const storage = new NodeStorage(tmpRoot); + await expect(storage.get('good')).resolves.toBe('keep'); + await expect(storage.get('obj')).resolves.toBeNull(); + await expect(storage.get('arr')).resolves.toBeNull(); + await expect(storage.get('num')).resolves.toBeNull(); +}); + +it('does not follow a symlink planted at the temp file path', async () => { + const storage = new NodeStorage(tmpRoot); + // Ensure initialization (which clears any temp file) has completed before planting. + await storage.get('warmup'); + + const victim = path.join(tmpRoot, 'victim.txt'); + await fs.writeFile(victim, 'protected', 'utf8'); + await fs.symlink(victim, path.join(tmpRoot, 'ldcache.json.tmp')); + + await storage.set('alpha', 'one'); + + // The exclusive open removes the symlink and writes a fresh file, so the victim is untouched. + await expect(fs.readFile(victim, 'utf8')).resolves.toBe('protected'); + await expect(storage.get('alpha')).resolves.toBe('one'); +}); + it('logs and returns sentinel values when initialization fails', async () => { const filePath = path.join(tmpRoot, 'not-a-dir'); await fs.writeFile(filePath, 'sentinel', 'utf8'); @@ -104,3 +146,14 @@ it('rebuilds the singleton after resetNodeStorage', () => { const second = getNodeStorage(tmpRoot); expect(second).not.toBe(first); }); + +it('warns when getNodeStorage is called with a different localStoragePath', () => { + getNodeStorage(tmpRoot); + + const logger = createMockLogger(); + getNodeStorage(path.join(tmpRoot, 'different'), logger); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('different localStoragePath'), + ); +}); diff --git a/packages/sdk/node-client/src/platform/NodeResponse.ts b/packages/sdk/node-client/src/platform/NodeResponse.ts index 52bb134f38..04758346a8 100644 --- a/packages/sdk/node-client/src/platform/NodeResponse.ts +++ b/packages/sdk/node-client/src/platform/NodeResponse.ts @@ -8,7 +8,7 @@ import HeaderWrapper from './HeaderWrapper'; // Upper bound on a buffered response body. Flag and event responses are far smaller than this; // the cap prevents a misbehaving or hostile endpoint from exhausting memory with a huge body. -const MAX_RESPONSE_BYTES = 100 * 1024 * 1024; +export const MAX_RESPONSE_BYTES = 100 * 1024 * 1024; export default class NodeResponse implements platform.Response { incomingMessage: http.IncomingMessage;