Skip to content

Commit d97ce82

Browse files
authored
fix: Add RN SDK offline support through ConnectionMode. (#361)
This adds offline support for the RN SDK through ConnectionMode. ```tsx /** * Sets the mode to use for connections when the SDK is initialized. * * Defaults to streaming. */ initialConnectionMode?: ConnectionMode; // in api/LDOptions.ts /** * Sets the SDK connection mode. * * @param mode - One of supported {@link ConnectionMode}. By default, the SDK uses 'streaming'. */ setConnectionMode(mode: ConnectionMode): void; // in api/LDClient.ts ```
1 parent c69b768 commit d97ce82

File tree

14 files changed

+186
-66
lines changed

14 files changed

+186
-66
lines changed

packages/sdk/react-native/example/e2e/starter.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ describe('Example', () => {
3131

3232
test('variation', async () => {
3333
await element(by.id('flagKey')).replaceText('my-boolean-flag-2');
34-
await element(by.text(/get flag value/i)).tap();
3534

3635
await waitFor(element(by.text(/my-boolean-flag-2: true/i)))
3736
.toBeVisible()

packages/sdk/react-native/example/src/welcome.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from 'react';
22
import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
33

4+
import { ConnectionMode } from '@launchdarkly/js-client-sdk-common';
45
import { useBoolVariation, useLDClient } from '@launchdarkly/react-native-client-sdk';
56

67
export default function Welcome() {
@@ -15,6 +16,10 @@ export default function Welcome() {
1516
.catch((e: any) => console.error(`error identifying ${userKey}: ${e}`));
1617
};
1718

19+
const setConnectionMode = (m: ConnectionMode) => {
20+
ldc.setConnectionMode(m);
21+
};
22+
1823
return (
1924
<View style={styles.container}>
2025
<Text>Welcome to LaunchDarkly</Text>
@@ -40,8 +45,14 @@ export default function Welcome() {
4045
value={flagKey}
4146
testID="flagKey"
4247
/>
43-
<TouchableOpacity style={styles.buttonContainer}>
44-
<Text style={styles.buttonText}>get flag value</Text>
48+
<TouchableOpacity style={styles.buttonContainer} onPress={() => setConnectionMode('offline')}>
49+
<Text style={styles.buttonText}>Set offline</Text>
50+
</TouchableOpacity>
51+
<TouchableOpacity
52+
style={styles.buttonContainer}
53+
onPress={() => setConnectionMode('streaming')}
54+
>
55+
<Text style={styles.buttonText}>Set online</Text>
4556
</TouchableOpacity>
4657
</View>
4758
);

packages/shared/common/__tests__/internal/events/EventProcessor.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -686,10 +686,11 @@ describe('given an event processor', () => {
686686
eventProcessor.sendEvent(new InputIdentifyEvent(Context.fromLDContext(user)));
687687
await jest.advanceTimersByTimeAsync(eventProcessorConfig.flushInterval * 1000);
688688

689-
expect(mockConsole).toBeCalledTimes(2);
690-
expect(mockConsole).toHaveBeenNthCalledWith(1, 'debug: [LaunchDarkly] Flushing 1 events');
689+
expect(mockConsole).toHaveBeenCalledTimes(3);
690+
expect(mockConsole).toHaveBeenNthCalledWith(1, 'debug: [LaunchDarkly] Started EventProcessor.');
691+
expect(mockConsole).toHaveBeenNthCalledWith(2, 'debug: [LaunchDarkly] Flushing 1 events');
691692
expect(mockConsole).toHaveBeenNthCalledWith(
692-
2,
693+
3,
693694
'debug: [LaunchDarkly] Flush failed: Error: some error',
694695
);
695696
});

packages/shared/common/src/internal/events/EventProcessor.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,11 @@ export default class EventProcessor implements LDEventProcessor {
104104
private flushUsersTimer: any = null;
105105

106106
constructor(
107-
config: EventProcessorOptions,
107+
private readonly config: EventProcessorOptions,
108108
clientContext: ClientContext,
109109
private readonly contextDeduplicator?: LDContextDeduplicator,
110110
private readonly diagnosticsManager?: DiagnosticsManager,
111+
start: boolean = true,
111112
) {
112113
this.capacity = config.eventsCapacity;
113114
this.logger = clientContext.basicConfiguration.logger;
@@ -118,6 +119,12 @@ export default class EventProcessor implements LDEventProcessor {
118119
config.privateAttributes.map((ref) => new AttributeReference(ref)),
119120
);
120121

122+
if (start) {
123+
this.start();
124+
}
125+
}
126+
127+
start() {
121128
if (this.contextDeduplicator?.flushInterval !== undefined) {
122129
this.flushUsersTimer = setInterval(() => {
123130
this.contextDeduplicator?.flush();
@@ -131,10 +138,10 @@ export default class EventProcessor implements LDEventProcessor {
131138
// Log errors and swallow them
132139
this.logger?.debug(`Flush failed: ${e}`);
133140
}
134-
}, config.flushInterval * 1000);
141+
}, this.config.flushInterval * 1000);
135142

136143
if (this.diagnosticsManager) {
137-
const initEvent = diagnosticsManager!.createInitEvent();
144+
const initEvent = this.diagnosticsManager!.createInitEvent();
138145
this.postDiagnosticEvent(initEvent);
139146

140147
this.diagnosticsTimer = setInterval(() => {
@@ -148,8 +155,10 @@ export default class EventProcessor implements LDEventProcessor {
148155
this.deduplicatedUsers = 0;
149156

150157
this.postDiagnosticEvent(statsEvent);
151-
}, config.diagnosticRecordingInterval * 1000);
158+
}, this.config.diagnosticRecordingInterval * 1000);
152159
}
160+
161+
this.logger?.debug('Started EventProcessor.');
153162
}
154163

155164
private postDiagnosticEvent(event: DiagnosticEvent) {

packages/shared/sdk-client/src/LDClientImpl.ts

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@ import {
1414
Platform,
1515
ProcessStreamResponse,
1616
EventName as StreamEventName,
17-
subsystem,
1817
TypeValidators,
1918
} from '@launchdarkly/js-sdk-common';
2019

21-
import { LDClient, type LDOptions } from './api';
20+
import { ConnectionMode, LDClient, type LDOptions } from './api';
2221
import LDEmitter, { EventName } from './api/LDEmitter';
2322
import Configuration from './configuration';
2423
import createDiagnosticsManager from './diagnostics/createDiagnosticsManager';
@@ -34,9 +33,9 @@ export default class LDClientImpl implements LDClient {
3433
config: Configuration;
3534
context?: LDContext;
3635
diagnosticsManager?: internal.DiagnosticsManager;
37-
eventProcessor: subsystem.LDEventProcessor;
38-
streamer?: internal.StreamingProcessor;
36+
eventProcessor?: internal.EventProcessor;
3937
logger: LDLogger;
38+
streamer?: internal.StreamingProcessor;
4039

4140
private eventFactoryDefault = new EventFactory(false);
4241
private eventFactoryWithReasons = new EventFactory(true);
@@ -74,10 +73,45 @@ export default class LDClientImpl implements LDClient {
7473
this.config,
7574
platform,
7675
this.diagnosticsManager,
76+
!this.isOffline(),
7777
);
7878
this.emitter = new LDEmitter();
7979
}
8080

81+
async setConnectionMode(mode: ConnectionMode): Promise<void> {
82+
if (this.config.connectionMode === mode) {
83+
this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`);
84+
return Promise.resolve();
85+
}
86+
87+
this.config.connectionMode = mode;
88+
this.logger.debug(`setConnectionMode ${mode}.`);
89+
90+
switch (mode) {
91+
case 'offline':
92+
return this.close();
93+
case 'streaming':
94+
this.eventProcessor?.start();
95+
96+
if (this.context) {
97+
// identify will start streamer
98+
return this.identify(this.context);
99+
}
100+
break;
101+
default:
102+
this.logger.warn(
103+
`Unknown ConnectionMode: ${mode}. Only 'offline' and 'streaming' are supported.`,
104+
);
105+
break;
106+
}
107+
108+
return Promise.resolve();
109+
}
110+
111+
isOffline() {
112+
return this.config.connectionMode === 'offline';
113+
}
114+
81115
allFlags(): LDFlagSet {
82116
const result: LDFlagSet = {};
83117
Object.entries(this.flags).forEach(([k, r]) => {
@@ -90,16 +124,20 @@ export default class LDClientImpl implements LDClient {
90124

91125
async close(): Promise<void> {
92126
await this.flush();
93-
this.eventProcessor.close();
127+
this.eventProcessor?.close();
94128
this.streamer?.close();
129+
this.logger.debug('Closed eventProcessor and streamer.');
95130
}
96131

97132
async flush(): Promise<{ error?: Error; result: boolean }> {
98133
try {
99-
await this.eventProcessor.flush();
134+
await this.eventProcessor?.flush();
135+
this.logger.debug('Successfully flushed eventProcessor.');
100136
} catch (e) {
137+
this.logger.error(`Error flushing eventProcessor: ${e}.`);
101138
return { error: e as Error, result: false };
102139
}
140+
103141
return { result: true };
104142
}
105143

@@ -232,7 +270,6 @@ export default class LDClientImpl implements LDClient {
232270
return f ? JSON.parse(f) : undefined;
233271
}
234272

235-
// TODO: implement secure mode
236273
async identify(pristineContext: LDContext, _hash?: string): Promise<void> {
237274
let context = await ensureKey(pristineContext, this.platform);
238275

@@ -262,19 +299,30 @@ export default class LDClientImpl implements LDClient {
262299
this.emitter.emit('change', context, changedKeys);
263300
}
264301

265-
this.streamer?.close();
266-
this.streamer = new internal.StreamingProcessor(
267-
this.sdkKey,
268-
this.clientContext,
269-
this.createStreamUriPath(context),
270-
this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve),
271-
this.diagnosticsManager,
272-
(e) => {
273-
this.logger.error(e);
274-
this.emitter.emit('error', context, e);
275-
},
276-
);
277-
this.streamer.start();
302+
if (this.isOffline()) {
303+
if (flagsStorage) {
304+
this.logger.debug('Offline identify using storage flags.');
305+
} else {
306+
this.logger.debug('Offline identify no storage. Defaults will be used.');
307+
this.context = context;
308+
this.flags = {};
309+
identifyResolve();
310+
}
311+
} else {
312+
this.streamer?.close();
313+
this.streamer = new internal.StreamingProcessor(
314+
this.sdkKey,
315+
this.clientContext,
316+
this.createStreamUriPath(context),
317+
this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve),
318+
this.diagnosticsManager,
319+
(e) => {
320+
this.logger.error(e);
321+
this.emitter.emit('error', context, e);
322+
},
323+
);
324+
this.streamer.start();
325+
}
278326

279327
return identifyPromise;
280328
}
@@ -298,13 +346,11 @@ export default class LDClientImpl implements LDClient {
298346
return;
299347
}
300348

301-
this.eventProcessor.sendEvent(
349+
this.eventProcessor?.sendEvent(
302350
this.eventFactoryDefault.customEvent(key, checkedContext!, data, metricValue),
303351
);
304352
}
305353

306-
// TODO: move variation functions to a separate file to make this file size
307-
// more manageable.
308354
private variationInternal(
309355
flagKey: string,
310356
defaultValue: any,
@@ -322,11 +368,11 @@ export default class LDClientImpl implements LDClient {
322368
if (!found || found.deleted) {
323369
const defVal = defaultValue ?? null;
324370
const error = new LDClientError(
325-
`Unknown feature flag "${flagKey}"; returning default value ${defVal}`,
371+
`Unknown feature flag "${flagKey}"; returning default value ${defVal}.`,
326372
);
327373
this.logger.error(error);
328374
this.emitter.emit('error', this.context, error);
329-
this.eventProcessor.sendEvent(
375+
this.eventProcessor?.sendEvent(
330376
this.eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext),
331377
);
332378
return createErrorEvaluationDetail(ErrorKinds.FlagNotFound, defaultValue);
@@ -337,7 +383,7 @@ export default class LDClientImpl implements LDClient {
337383
if (typeChecker) {
338384
const [matched, type] = typeChecker(value);
339385
if (!matched) {
340-
this.eventProcessor.sendEvent(
386+
this.eventProcessor?.sendEvent(
341387
eventFactory.evalEventClient(
342388
flagKey,
343389
defaultValue, // track default value on type errors
@@ -361,7 +407,7 @@ export default class LDClientImpl implements LDClient {
361407
this.logger.debug('Result value is null in variation');
362408
successDetail.value = defaultValue;
363409
}
364-
this.eventProcessor.sendEvent(
410+
this.eventProcessor?.sendEvent(
365411
eventFactory.evalEventClient(flagKey, value, defaultValue, found, evalContext, reason),
366412
);
367413
return successDetail;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* The connection mode for the SDK to use.
3+
*
4+
* @remarks
5+
*
6+
* The following connection modes are supported:
7+
*
8+
* offline - When the SDK is set offline it will stop receiving updates and will stop sending
9+
* analytic and diagnostic events.
10+
*
11+
* streaming - The SDK will use a streaming connection to receive updates from LaunchDarkly.
12+
*/
13+
type ConnectionMode = 'offline' | 'streaming';
14+
15+
export default ConnectionMode;

packages/shared/sdk-client/src/api/LDClient.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
LDLogger,
88
} from '@launchdarkly/js-sdk-common';
99

10+
import ConnectionMode from './ConnectionMode';
11+
1012
/**
1113
* The basic interface for the LaunchDarkly client. Platform-specific SDKs may add some methods of their own.
1214
*
@@ -213,6 +215,13 @@ export interface LDClient {
213215
*/
214216
on(key: string, callback: (...args: any[]) => void): void;
215217

218+
/**
219+
* Sets the SDK connection mode.
220+
*
221+
* @param mode - One of supported {@link ConnectionMode}. By default, the SDK uses 'streaming'.
222+
*/
223+
setConnectionMode(mode: ConnectionMode): void;
224+
216225
/**
217226
* Determines the string variation of a feature flag.
218227
*

packages/shared/sdk-client/src/api/LDOptions.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { LDFlagSet, LDLogger } from '@launchdarkly/js-sdk-common';
22

3+
import ConnectionMode from './ConnectionMode';
34
import type { LDInspection } from './LDInspection';
45

56
export interface LDOptions {
@@ -59,17 +60,11 @@ export interface LDOptions {
5960
baseUri?: string;
6061

6162
/**
63+
* TODO: bootstrap
6264
* The initial set of flags to use until the remote set is retrieved.
63-
*
64-
* If `"localStorage"` is specified, the flags will be saved and retrieved from browser local
65-
* storage. Alternatively, an {@link LDFlagSet} can be specified which will be used as the initial
66-
* source of flag values. In the latter case, the flag values will be available via {@link LDClient.variation}
67-
* immediately after calling `initialize()` (normally they would not be available until the
68-
* client signals that it is ready).
69-
*
70-
* For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/features/bootstrapping#javascript).
65+
* @alpha
7166
*/
72-
bootstrap?: 'localStorage' | LDFlagSet;
67+
bootstrap?: LDFlagSet;
7368

7469
/**
7570
* The capacity of the analytics events queue.
@@ -119,15 +114,23 @@ export interface LDOptions {
119114
flushInterval?: number;
120115

121116
/**
117+
* TODO: secure mode
122118
* The signed context key for Secure Mode.
123-
*
124-
* For more information, see the JavaScript SDK Reference Guide on
125-
* [Secure mode](https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk).
119+
* @alpha
126120
*/
127121
hash?: string;
128122

129123
/**
124+
* Sets the mode to use for connections when the SDK is initialized.
125+
*
126+
* Defaults to streaming.
127+
*/
128+
initialConnectionMode?: ConnectionMode;
129+
130+
/**
131+
* TODO: inspectors
130132
* Inspectors can be used for collecting information for monitoring, analytics, and debugging.
133+
* @alpha
131134
*/
132135
inspectors?: LDInspection[];
133136

0 commit comments

Comments
 (0)