Skip to content

Commit 01a2a59

Browse files
committed
chore: pr comments
1 parent a445510 commit 01a2a59

10 files changed

Lines changed: 297 additions & 30 deletions

File tree

packages/sdk/node-client/__tests__/NodeClient.bootstrap.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,25 @@ it('re-identifying with new bootstrap data replaces previous flags', async () =>
109109
expect(client.boolVariation('my-boolean-flag', false)).toBe(true);
110110
});
111111

112+
it('warns that waitForNetworkResults is ignored when combined with bootstrap', async () => {
113+
const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, {
114+
initialConnectionMode: 'offline',
115+
sendEvents: false,
116+
diagnosticOptOut: true,
117+
localStoragePath: tmpRoot,
118+
logger,
119+
});
120+
121+
await client.start({ bootstrap: goodBootstrapData });
122+
const result = await client.identify(
123+
{ kind: 'user', key: 'alice' },
124+
{ bootstrap: goodBootstrapData, waitForNetworkResults: true },
125+
);
126+
127+
expect(result.status).toBe('completed');
128+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('waitForNetworkResults'));
129+
});
130+
112131
it('returns defaults when no bootstrap data is provided', async () => {
113132
const client = createClient('client-side-id', { kind: 'user', key: 'bob' }, {
114133
initialConnectionMode: 'offline',

packages/sdk/node-client/__tests__/NodeClient.dataSource.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { LDLogger } from '@launchdarkly/js-client-sdk-common';
22

33
import { createClient } from '../src';
44
import NodeDataManager from '../src/NodeDataManager';
5+
import { NodeClient } from '../src/NodeClient';
56
import { makeMockPlatform, mockFetch } from './NodeClient.mocks';
67

78
// Replace NodePlatform's constructor with one that returns the mock platform. Lets us
@@ -307,3 +308,101 @@ it('setConnectionMode streaming -> offline tears down the EventSource', async ()
307308

308309
await client.close();
309310
});
311+
312+
it('keeps event-sending state consistent with the mode under concurrent setConnectionMode', async () => {
313+
const fakePlatform = makeMockPlatform({
314+
requests: {
315+
fetch: mockFetch('', 202),
316+
createEventSource: jest.fn(() => ({
317+
addEventListener: jest.fn(),
318+
close: jest.fn(),
319+
onclose: jest.fn(),
320+
onerror: jest.fn(),
321+
onopen: jest.fn(),
322+
onretrying: jest.fn(),
323+
})),
324+
getEventSourceCapabilities: () => ({ readTimeout: true, headers: true, customMethod: true }),
325+
},
326+
});
327+
NodePlatformMock.mockImplementationOnce(() => fakePlatform);
328+
329+
// Use the implementation directly so we can assert on the internal event-sending flag,
330+
// which governs background (timer-driven) analytics delivery.
331+
const client = new NodeClient(
332+
'client-side-id',
333+
{ kind: 'user', key: 'bob' },
334+
{
335+
initialConnectionMode: 'streaming',
336+
sendEvents: true,
337+
diagnosticOptOut: true,
338+
logger,
339+
},
340+
);
341+
342+
await client.start({ bootstrap: bootstrapData });
343+
344+
// Fire two transitions without awaiting between them. Without serialization the offline
345+
// transition could settle while event-sending is left enabled.
346+
const p1 = client.setConnectionMode('streaming');
347+
const p2 = client.setConnectionMode('offline');
348+
await Promise.all([p1, p2]);
349+
350+
expect(client.getConnectionMode()).toBe('offline');
351+
expect(client.isOffline()).toBe(true);
352+
// When offline, background analytics delivery must be disabled.
353+
// eslint-disable-next-line no-underscore-dangle
354+
expect((client as any)._eventSendingEnabled).toBe(false);
355+
356+
await client.close();
357+
});
358+
359+
it('rejects identify called after close without waiting for the identify timeout', async () => {
360+
const fakePlatform = makeMockPlatform();
361+
NodePlatformMock.mockImplementationOnce(() => fakePlatform);
362+
363+
const client = createClient(
364+
'client-side-id',
365+
{ kind: 'user', key: 'bob' },
366+
{
367+
initialConnectionMode: 'streaming',
368+
sendEvents: false,
369+
diagnosticOptOut: true,
370+
logger,
371+
},
372+
);
373+
374+
await client.start({ bootstrap: bootstrapData });
375+
await client.close();
376+
377+
const start = Date.now();
378+
const result = await client.identify({ kind: 'user', key: 'alice' });
379+
const elapsed = Date.now() - start;
380+
381+
expect(result.status).toBe('error');
382+
// Should fail fast, not sit until the 5s identify timeout.
383+
expect(elapsed).toBeLessThan(1000);
384+
});
385+
386+
it('does not read cached flags when bootstrap is provided', async () => {
387+
const fakePlatform = makeMockPlatform();
388+
NodePlatformMock.mockImplementationOnce(() => fakePlatform);
389+
390+
const client = createClient(
391+
'client-side-id',
392+
{ kind: 'user', key: 'bob' },
393+
{
394+
initialConnectionMode: 'streaming',
395+
sendEvents: false,
396+
diagnosticOptOut: true,
397+
logger,
398+
},
399+
);
400+
401+
await client.start({ bootstrap: bootstrapData });
402+
403+
// Bootstrap and cache are mutually exclusive: cached flags must not be consulted (and so
404+
// cannot overwrite) the freshly applied bootstrap data.
405+
expect(fakePlatform.storage!.get).not.toHaveBeenCalled();
406+
407+
await client.close();
408+
});

packages/sdk/node-client/__tests__/NodeClient.events.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,63 @@ it('includes authorization and user-agent headers on the events request', async
166166

167167
await client.close();
168168
});
169+
170+
it('delivers events tracked across an offline transition once back online', async () => {
171+
const fetchMock = mockFetch('', 202);
172+
const fakePlatform = makeMockPlatform({
173+
requests: {
174+
fetch: fetchMock,
175+
createEventSource: jest.fn(() => ({
176+
addEventListener: jest.fn(),
177+
close: jest.fn(),
178+
onclose: jest.fn(),
179+
onerror: jest.fn(),
180+
onopen: jest.fn(),
181+
onretrying: jest.fn(),
182+
})),
183+
getEventSourceCapabilities: () => ({ readTimeout: true, headers: true, customMethod: true }),
184+
},
185+
});
186+
NodePlatformMock.mockImplementationOnce(() => fakePlatform);
187+
188+
const client = createClient(
189+
'client-side-id',
190+
{ kind: 'user', key: 'bob' },
191+
{
192+
initialConnectionMode: 'streaming',
193+
sendEvents: true,
194+
diagnosticOptOut: true,
195+
logger,
196+
},
197+
);
198+
199+
await client.start({ bootstrap: bootstrapData });
200+
201+
client.track('eventA');
202+
await client.setConnectionMode('offline');
203+
client.track('eventB');
204+
await client.setConnectionMode('streaming');
205+
client.track('eventC');
206+
await client.flush();
207+
208+
const customKeys = new Set<string>();
209+
fetchMock.mock.calls
210+
.filter(([url]: [string]) => url.includes('/events/bulk/'))
211+
.forEach((call: any) => {
212+
try {
213+
JSON.parse(call[1].body).forEach((e: any) => {
214+
if (e.kind === 'custom') {
215+
customKeys.add(e.key);
216+
}
217+
});
218+
} catch {
219+
// not JSON, skip
220+
}
221+
});
222+
223+
expect(customKeys.has('eventA')).toBe(true);
224+
expect(customKeys.has('eventB')).toBe(true);
225+
expect(customKeys.has('eventC')).toBe(true);
226+
227+
await client.close();
228+
});

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import NodePlatform from './platform/NodePlatform';
2828
export class NodeClient extends LDClientImpl {
2929
private readonly _plugins: LDPlugin[];
3030

31+
// Serializes connection-mode transitions so concurrent calls cannot leave event-sending
32+
// state out of sync with the active connection mode.
33+
private _connectionModeQueue: Promise<void> = Promise.resolve();
34+
3135
constructor(envKey: string, initialContext: LDContext, options: NodeOptions = {}) {
3236
const { logger: customLogger, debug } = options;
3337
const logger = customLogger ?? basicLogger({ level: debug ? 'debug' : 'info' });
@@ -45,7 +49,7 @@ export class NodeClient extends LDClientImpl {
4549
initialContext,
4650
};
4751

48-
const platform = new NodePlatform(logger, options);
52+
const platform = new NodePlatform(logger, validatedNodeOptions);
4953
const endpoints = browserFdv1Endpoints(envKey);
5054

5155
super(
@@ -99,14 +103,24 @@ export class NodeClient extends LDClientImpl {
99103
}
100104

101105
async setConnectionMode(mode: ConnectionMode): Promise<void> {
102-
if (mode === 'offline') {
103-
this.setEventSendingEnabled(false, true);
104-
}
105-
const dataManager = this.dataManager as NodeDataManager;
106-
await dataManager.setConnectionMode(mode);
107-
if (mode !== 'offline') {
108-
this.setEventSendingEnabled(true, false);
109-
}
106+
const task = this._connectionModeQueue.then(async () => {
107+
const dataManager = this.dataManager as NodeDataManager;
108+
if (mode === 'offline') {
109+
// Disable analytics, then drain any queued events before tearing down the data source.
110+
this.setEventSendingEnabled(false, false);
111+
await this.flush();
112+
}
113+
try {
114+
await dataManager.setConnectionMode(mode);
115+
} finally {
116+
// Read the mode back so event-sending always matches the mode that actually took
117+
// effect, even if the transition failed partway.
118+
this.setEventSendingEnabled(dataManager.getConnectionMode() !== 'offline', false);
119+
}
120+
});
121+
// Keep the queue alive even if a transition fails; the failure still propagates to this caller.
122+
this._connectionModeQueue = task.catch(() => {});
123+
return task;
110124
}
111125

112126
getConnectionMode(): ConnectionMode {

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

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ConnectionMode,
55
Context,
66
DataSourcePaths,
7+
DataSourceState,
78
FlagManager,
89
internal,
910
LDEmitter,
@@ -19,7 +20,6 @@ import type { ValidatedOptions } from './options';
1920
const logTag = '[NodeDataManager]';
2021

2122
export default class NodeDataManager extends BaseDataManager {
22-
protected networkAvailable: boolean = true;
2323
protected connectionMode: ConnectionMode = 'streaming';
2424

2525
constructor(
@@ -60,21 +60,36 @@ export default class NodeDataManager extends BaseDataManager {
6060
): Promise<void> {
6161
if (this.closed) {
6262
this._debugLog('Identify called after data manager was closed.');
63+
identifyReject(new Error('Client has been closed.'));
6364
return;
6465
}
6566
this.context = context;
6667

67-
let identifyResolved = false;
68+
const offline = this.connectionMode === 'offline';
69+
70+
// Bootstrap and cache are mutually exclusive: when bootstrap data is provided it
71+
// resolves identify immediately, so we must not also load (and potentially overwrite
72+
// with) stale cached flags.
6873
if (identifyOptions?.bootstrap) {
74+
if (identifyOptions.waitForNetworkResults) {
75+
this.logger.warn(
76+
`${logTag} 'waitForNetworkResults' is ignored when 'bootstrap' is provided.`,
77+
);
78+
}
6979
this._finishIdentifyFromBootstrap(context, identifyOptions, identifyResolve);
70-
identifyResolved = true;
80+
if (!offline) {
81+
// Open a connection for ongoing updates, but identify is already resolved so no
82+
// callbacks are forwarded.
83+
this._setupConnection(context);
84+
}
85+
return;
7186
}
7287

73-
const offline = this.connectionMode === 'offline';
7488
const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline;
7589

7690
const loadedFromCache = await this.flagManager.loadCached(context);
77-
if (loadedFromCache && !waitForNetworkResults && !identifyResolved) {
91+
let identifyResolved = false;
92+
if (loadedFromCache && !waitForNetworkResults) {
7893
this._debugLog('Identify completing with cached flags');
7994
identifyResolve();
8095
identifyResolved = true;
@@ -87,9 +102,7 @@ export default class NodeDataManager extends BaseDataManager {
87102
this._debugLog(
88103
'Offline identify - no cached flags, using defaults or already loaded flags.',
89104
);
90-
if (!identifyResolved) {
91-
identifyResolve();
92-
}
105+
identifyResolve();
93106
}
94107
return;
95108
}
@@ -111,6 +124,7 @@ export default class NodeDataManager extends BaseDataManager {
111124
bootstrapParsed = readFlagsFromBootstrap(this.logger, identifyOpts.bootstrap);
112125
}
113126
this.flagManager.setBootstrap(context, bootstrapParsed);
127+
this.dataSourceStatusManager.requestStateUpdate(DataSourceState.Valid);
114128
this._debugLog('Identify - Initialization completed from bootstrap');
115129

116130
identifyResolve();
@@ -121,7 +135,12 @@ export default class NodeDataManager extends BaseDataManager {
121135
identifyResolve?: () => void,
122136
identifyReject?: (err: Error) => void,
123137
) {
124-
const rawContext = Context.toLDContext(context)!;
138+
const rawContext = Context.toLDContext(context);
139+
if (!rawContext) {
140+
this.logger.error(`${logTag} Unable to convert context; cannot establish connection.`);
141+
identifyReject?.(new Error('Invalid context.'));
142+
return;
143+
}
125144

126145
const plainContextString = JSON.stringify(rawContext);
127146
const requestor = makeRequestor(
@@ -161,15 +180,12 @@ export default class NodeDataManager extends BaseDataManager {
161180
this.logger.warn(
162181
`${logTag} _setupConnection called with unsupported connectionMode '${this.connectionMode}'.`,
163182
);
183+
this.updateProcessor = undefined;
164184
return;
165185
}
166186
this.updateProcessor!.start();
167187
}
168188

169-
setNetworkAvailability(available: boolean): void {
170-
this.networkAvailable = available;
171-
}
172-
173189
async setConnectionMode(mode: ConnectionMode): Promise<void> {
174190
if (this.closed) {
175191
this._debugLog('setting connection mode after data manager was closed');
@@ -187,6 +203,7 @@ export default class NodeDataManager extends BaseDataManager {
187203
switch (mode) {
188204
case 'offline':
189205
this.updateProcessor?.close();
206+
this.updateProcessor = undefined;
190207
break;
191208
case 'polling':
192209
case 'streaming':

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,12 @@ export default function validateOptions(opts: NodeOptions, logger: LDLogger): Va
7272
}
7373
});
7474

75+
if (output.tlsParams?.rejectUnauthorized === false) {
76+
logger.warn(
77+
'TLS certificate verification is disabled via tlsParams.rejectUnauthorized=false. ' +
78+
'This is insecure and should not be used in production.',
79+
);
80+
}
81+
7582
return output;
7683
}

0 commit comments

Comments
 (0)