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..537d5cc5ec --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts @@ -0,0 +1,144 @@ +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('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', + 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.dataSource.test.ts b/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts new file mode 100644 index 0000000000..02f9fcbd29 --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts @@ -0,0 +1,668 @@ +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 +// 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(); +}); + +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(); +}); + +it('rejects an in-flight identify immediately when close() is called while the processor awaits its first event', async () => { + // The streaming processor never fires a 'put' event, simulating a slow or stalled stream. + // close() must reject _pendingIdentifyReject promptly so the caller is not left waiting for + // the 5-second identify timeout. + 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, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + + // Identify without bootstrap: cache miss -> _setupConnection -> processor running, no 'put' yet. + const identifyPromise = client.identify({ kind: 'user', key: 'alice' }, { timeout: 5 }); + // Allow the identify to reach the streaming processor before closing. + await new Promise((resolve) => setTimeout(resolve, 10)); + + await client.close(); + + const result = await identifyPromise; + expect(result.status).toBe('error'); +}); + +it('rejects an in-flight identify when setConnectionMode offline runs after the processor started', async () => { + // The processor phase: identify has passed loadCached and _setupConnection has registered + // _pendingIdentifyReject. The stream never delivers a 'put', so the only way out is the + // reject path. + 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, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + + const identifyPromise = client.identify({ kind: 'user', key: 'alice' }, { timeout: 5 }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Go offline while the streaming processor is running and waiting for its first event. + await client.setConnectionMode('offline'); + + const result = await identifyPromise; + expect(result.status).toBe('error'); + + await client.close(); +}); + +it('rejects an in-flight identify when setConnectionMode switches modes while the processor is running', async () => { + // Switching from streaming to polling replaces the active processor. The pending identify + // on the old processor must be rejected rather than silently abandoned. + 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, + logger, + }, + ); + + await client.start({ bootstrap: bootstrapData }); + + const identifyPromise = client.identify({ kind: 'user', key: 'alice' }, { timeout: 5 }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Switch to polling -- the old streaming processor is replaced; the pending identify rejects. + await client.setConnectionMode('polling'); + + const result = await identifyPromise; + expect(result.status).toBe('error'); + + await client.close(); +}); + +it('rejects identify fast when close() runs while loadCached is in flight', async () => { + // Gate the cached-flag read so we can close the client while identify is parked on + // the await - reproducing the race where the post-await path would otherwise resolve + // or start a processor on a closed client. + 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 }); + + const start = Date.now(); + // A second identify without bootstrap parks on the gated storage.get. + const identifyPromise = client.identify({ kind: 'user', key: 'alice' }, { timeout: 5 }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Close the client while identify is parked, then release the cache read. + await client.close(); + releaseGet(); + + const result = await identifyPromise; + const elapsed = Date.now() - start; + expect(result.status).toBe('error'); + // Should fail fast -- the post-await closed-check rejects rather than waiting on the timeout. + expect(elapsed).toBeLessThan(1000); +}); + +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/__tests__/NodeClient.events.test.ts b/packages/sdk/node-client/__tests__/NodeClient.events.test.ts new file mode 100644 index 0000000000..70a7bf59ab --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.events.test.ts @@ -0,0 +1,228 @@ +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(); +}); + +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/__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 new file mode 100644 index 0000000000..800eb78fa9 --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.test.ts @@ -0,0 +1,99 @@ +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(typeof client.getContext).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/src/NodeClient.ts b/packages/sdk/node-client/src/NodeClient.ts new file mode 100644 index 0000000000..213deba041 --- /dev/null +++ b/packages/sdk/node-client/src/NodeClient.ts @@ -0,0 +1,174 @@ +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, validatedNodeOptions); + 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 { + const dataManager = this.dataManager as NodeDataManager; + return dataManager.setConnectionMode( + mode, + () => this.flush(), + (enabled) => this.setEventSendingEnabled(enabled, 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. + */ +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..ea9340cd15 --- /dev/null +++ b/packages/sdk/node-client/src/NodeDataManager.ts @@ -0,0 +1,316 @@ +import { + BaseDataManager, + Configuration, + ConnectionMode, + Context, + DataSourcePaths, + DataSourceState, + 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 connectionMode: ConnectionMode = 'streaming'; + private _pendingIdentifyReject?: (err: Error) => void; + // Serializes connection-mode transitions so concurrent calls cannot leave state + // (event-sending, processor, mode) out of sync. + private _connectionModeQueue: Promise = Promise.resolve(); + + 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); + } + + // Capture-then-clear-then-invoke the pending identify reject so a re-entrant call from + // the reject handler cannot observe a stale callback. + private _rejectPendingIdentify(error: Error): void { + if (this._pendingIdentifyReject) { + const reject = this._pendingIdentifyReject; + this._pendingIdentifyReject = undefined; + reject(error); + } + } + + 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.'); + identifyReject(new Error('Client has been closed.')); + return; + } + this.context = context; + + // Snapshot the mode before any await so the bootstrap path and the stale-snapshot + // detection below both see a consistent starting point. + const startedOffline = 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); + if (!startedOffline) { + // Open a connection for ongoing updates, but identify is already resolved so no + // callbacks are forwarded. + this._setupConnection(context); + } + return; + } + + const loadedFromCache = await this.flagManager.loadCached(context); + if (this.closed) { + this._debugLog('Identify called after data manager was closed (during cache load).'); + identifyReject(new Error('Client has been closed.')); + return; + } + // Re-read connectionMode after the await: a concurrent setConnectionMode call may have + // changed it while loadCached was in flight. + const offline = this.connectionMode === 'offline'; + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; + let identifyResolved = false; + if (loadedFromCache && !waitForNetworkResults) { + this._debugLog('Identify completing with cached flags'); + identifyResolve(); + identifyResolved = true; + } + + if (offline) { + if (!startedOffline) { + // The connection mode changed to offline while we were awaiting the cache. Reject + // rather than silently resolve so the caller knows the identify did not complete + // in the originally-requested mode. + if (!identifyResolved) { + identifyReject(new Error("Connection mode changed to 'offline' during identify.")); + } + return; + } + if (loadedFromCache) { + this._debugLog('Offline identify - using cached flags.'); + } else { + this._debugLog( + 'Offline identify - no cached flags, using defaults or already loaded flags.', + ); + identifyResolve(); + } + return; + } + + if (identifyResolved) { + this._setupConnection(context); + } 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.dataSourceStatusManager.requestStateUpdate(DataSourceState.Valid); + this._debugLog('Identify - Initialization completed from bootstrap'); + + identifyResolve(); + } + + private _setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context); + if (!rawContext) { + this.logger.error(`${logTag} Unable to convert context; cannot establish connection.`); + identifyReject?.(new Error('Invalid context.')); + return; + } + + // Wrap callbacks so _pendingIdentifyReject is cleared as soon as the identify settles, + // preventing a stale reject from firing if setConnectionMode runs after resolution. + const wrappedResolve = identifyResolve + ? () => { + this._pendingIdentifyReject = undefined; + identifyResolve(); + } + : undefined; + const wrappedReject = identifyReject + ? (err: Error) => { + this._pendingIdentifyReject = undefined; + identifyReject(err); + } + : undefined; + this._pendingIdentifyReject = wrappedReject; + + 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, + wrappedResolve, + wrappedReject, + ); + break; + case 'polling': + this.createPollingProcessor( + rawContext, + context, + requestor, + wrappedResolve, + wrappedReject, + ); + break; + default: + this.logger.warn( + `${logTag} _setupConnection called with unsupported connectionMode '${this.connectionMode}'.`, + ); + this.updateProcessor = undefined; + // connectionMode is an unsupported value; reject the in-flight identify so the + // promise does not hang until its timeout. + wrappedReject?.( + new Error(`Connection mode changed to '${this.connectionMode}' during identify.`), + ); + return; + } + this.updateProcessor!.start(); + } + + async setConnectionMode( + mode: ConnectionMode, + flush?: () => Promise, + setEventSendingEnabled?: (enabled: boolean) => void, + ): Promise { + const task = this._connectionModeQueue.then(async () => { + try { + 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; + } + + if (mode === 'offline') { + // Drain queued analytics before tearing down the data source, then stop + // accepting new events. + await flush?.(); + setEventSendingEnabled?.(false); + } + + this.connectionMode = mode; + this._debugLog(`setConnectionMode ${mode}.`); + + switch (mode) { + case 'offline': + // The processor's close() does not invoke pending callbacks, so reject the + // in-flight identify here to keep its promise from hanging until timeout. + this._rejectPendingIdentify( + new Error("Connection mode changed to 'offline' during identify."), + ); + this.updateProcessor?.close(); + this.updateProcessor = undefined; + break; + case 'polling': + case 'streaming': + if (this.context) { + // Reject any in-flight identify from the previous processor before replacing it. + this._rejectPendingIdentify( + new Error(`Connection mode changed to '${mode}' during identify.`), + ); + this._setupConnection(this.context); + } + break; + default: + this.logger.warn( + `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, + ); + break; + } + } finally { + // Re-sync event-sending against the mode that actually took effect, even on early + // returns and failures. Skip when closed -- a closed client must not restart event + // sending or any side effects gated on the enabled flag. + if (!this.closed) { + setEventSendingEnabled?.(this.connectionMode !== 'offline'); + } + } + }); + // Keep the queue alive even if a transition fails; the failure still propagates to this caller. + this._connectionModeQueue = task.catch(() => {}); + return task; + } + + getConnectionMode(): ConnectionMode { + return this.connectionMode; + } + + override close(): void { + this._rejectPendingIdentify(new Error('Client has been closed.')); + super.close(); + } +} diff --git a/packages/sdk/node-client/src/index.ts b/packages/sdk/node-client/src/index.ts index c9d105c957..8ebb8ae699 100644 --- a/packages/sdk/node-client/src/index.ts +++ b/packages/sdk/node-client/src/index.ts @@ -1,6 +1,68 @@ -// 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}, which provides access to all of the SDK's functionality. + * + * For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/client-side/node-js). + * + * @packageDocumentation + */ +import type { LDContext } from '@launchdarkly/js-client-sdk-common'; -export const version = '0.0.4'; // x-release-please-version +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 }; + +/** + * Creates an instance of the LaunchDarkly client. Note that the client will not be ready to + * use until {@link LDClient.start} is called. + * + * Usage: + * ``` + * import { createClient } from '@launchdarkly/node-client-sdk'; + * const client = createClient(clientSideId, context, options); + * + * // Attach event listeners and add any additional logic here + * + * // Then start the client + * client.start(); + * ``` + * @remarks + * The client will not automatically start until {@link LDClient.start} is called in order to + * synchronize the registering of event listeners and other initialization logic that should be + * done before the client initiates its connection to LaunchDarkly. + * + * @param envKey + * The client-side ID, also known as the environment ID. + * @param initialContext + * The initial context used to identify the user. @see {@link LDContext} + * @param options + * Optional configuration settings. @see {@link LDOptions} + * @returns + * The new client instance. @see {@link LDClient} + */ +export function createClient( + envKey: string, + initialContext: LDContext, + options: NodeOptions = {}, +): LDClient { + return makeClient(envKey, initialContext, options); +} diff --git a/release-please-config.json b/release-please-config.json index 072cd4cff9..627389979c 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -156,8 +156,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": {