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 } +} 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..f5ac11ac41 --- /dev/null +++ b/packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts @@ -0,0 +1,469 @@ +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 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/__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/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..1c7a0fe0ca --- /dev/null +++ b/packages/sdk/node-client/src/LDClient.ts @@ -0,0 +1,52 @@ +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; + + /** + * 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/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..249e19b046 --- /dev/null +++ b/packages/sdk/node-client/src/NodeClient.ts @@ -0,0 +1,191 @@ +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[]; + + // 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' }); + + 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 task = this._connectionModeQueue.then(async () => { + const dataManager = this.dataManager as NodeDataManager; + 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 + // 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 { + 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..393005731b --- /dev/null +++ b/packages/sdk/node-client/src/NodeDataManager.ts @@ -0,0 +1,230 @@ +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'; + + 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.'); + identifyReject(new Error('Client has been closed.')); + return; + } + this.context = context; + + 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); + if (!offline) { + // Open a connection for ongoing updates, but identify is already resolved so no + // callbacks are forwarded. + this._setupConnection(context); + } + return; + } + + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; + + const loadedFromCache = await this.flagManager.loadCached(context); + let identifyResolved = false; + if (loadedFromCache && !waitForNetworkResults) { + this._debugLog('Identify completing with cached flags'); + identifyResolve(); + identifyResolved = true; + } + + 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.', + ); + 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; + } + + 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: + this.logger.warn( + `${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(); + } + + 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(); + this.updateProcessor = undefined; + 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..cd7599287e 100644 --- a/packages/sdk/node-client/src/index.ts +++ b/packages/sdk/node-client/src/index.ts @@ -1,6 +1,49 @@ -// 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'; -export const version = '0.0.1'; // 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 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..734ac0c807 --- /dev/null +++ b/packages/sdk/node-client/src/options.ts @@ -0,0 +1,83 @@ +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)); + } + } + }); + + 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..04758346a8 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. +export 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; } 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": {