Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
cf3131a
WIP on goals.
kinyoklion Sep 17, 2024
543cfc9
Add tests for GoalTracker.
kinyoklion Sep 17, 2024
0288923
Add goal manager tests.
kinyoklion Sep 17, 2024
56684f7
Add options for goals and fix bugs.
kinyoklion Sep 18, 2024
97a90ed
Merge branch 'main' into rlamb/sc-254419/implement-goals
kinyoklion Sep 18, 2024
f30141a
Simplify tests.
kinyoklion Sep 18, 2024
40f78fe
Lint browser package.
kinyoklion Sep 18, 2024
2ff006c
Lint
kinyoklion Sep 18, 2024
a11e73d
Add tests for location watcher.
kinyoklion Sep 18, 2024
7067a28
WIP: Implement support for event URLs.
kinyoklion Sep 18, 2024
fdf4a43
feat: Add URLs for custom events and URL filtering.
kinyoklion Sep 19, 2024
29cd72a
Merge branch 'main' into rlamb/sdk-10/support-event-urls
kinyoklion Sep 19, 2024
2f730ab
Revert jest.config.js
kinyoklion Sep 19, 2024
dbfe431
Remove duplicate merged code.
kinyoklion Sep 19, 2024
57e835d
Remove unused import.
kinyoklion Sep 19, 2024
bcb3c4d
WIP: Refactor data handling.
kinyoklion Sep 19, 2024
0695c74
WIP: JS style initialization.
kinyoklion Sep 20, 2024
1439258
Add correct typing to createIdentifyPromise.
kinyoklion Sep 20, 2024
312fb49
Basic data manager functioning.
kinyoklion Sep 20, 2024
4318393
Basic functionality.
kinyoklion Sep 20, 2024
dd0e5fb
fix: Ensure browser contract tests run during top-level build.
kinyoklion Sep 23, 2024
f35f804
WIP: js-style-initialization
kinyoklion Sep 23, 2024
e020e86
Merge remote-tracking branch 'origin/rlamb/fix-browser-contract-test-…
kinyoklion Sep 23, 2024
80b5b61
Disable goals.
kinyoklion Sep 23, 2024
a83184d
Fix identify options.
kinyoklion Sep 23, 2024
c3cbd46
Merge remote-tracking branch 'origin/rlamb/fix-browser-contract-test-…
kinyoklion Sep 23, 2024
bba88a1
Add todo.
kinyoklion Sep 23, 2024
22b89a9
WIP
kinyoklion Sep 23, 2024
d1a59c8
Lint
kinyoklion Sep 23, 2024
60ea015
Fix tests.
kinyoklion Sep 23, 2024
2bdc298
Testing progress.
kinyoklion Sep 23, 2024
16b7732
More tests
kinyoklion Sep 24, 2024
fda525e
Lint.
kinyoklion Sep 24, 2024
b3b724f
Merge branch 'main' into rlamb/sdk-195/support-js-style-initialization
kinyoklion Sep 24, 2024
7722641
Cleanup sdk client tests.
kinyoklion Sep 24, 2024
045a784
Allow shared test code.
kinyoklion Sep 24, 2024
b097038
Cleanup imports
kinyoklion Sep 24, 2024
72139de
Revert event processor changes and disable auto-start for client SDKs.
kinyoklion Sep 24, 2024
6951966
Add log tag for mobile data manager.
kinyoklion Sep 24, 2024
3f1bb24
Remove pointless docs.
kinyoklion Sep 24, 2024
aa33fa4
Correct docs.
kinyoklion Sep 24, 2024
0da9d31
Change option to streaming.
kinyoklion Sep 24, 2024
1ef3126
feat: Automatically start streaming based on event handlers.
kinyoklion Sep 24, 2024
6d29928
Add truth table.
kinyoklion Sep 24, 2024
dc354d4
Automatic update streaming state on identify.
kinyoklion Sep 24, 2024
cced478
Add more tests.
kinyoklion Sep 24, 2024
10c48e7
Remove todo
kinyoklion Sep 24, 2024
df9dd9d
Remove event handler in base implementation.
kinyoklion Sep 24, 2024
5a3c8b4
Fix logger.
kinyoklion Sep 24, 2024
65e68cd
Merge branch 'main' into rlamb/sdk-710/automatic-start-streaming
kinyoklion Sep 25, 2024
1dedc7c
Lint
kinyoklion Sep 25, 2024
71caed5
Fix event tests. Add developer notes.
kinyoklion Sep 25, 2024
816a594
Revert unintentional merge change.
kinyoklion Sep 25, 2024
d13b8e4
Remove file that was re-created in merge.
kinyoklion Sep 25, 2024
fdd6331
Revert another merge change.
kinyoklion Sep 25, 2024
b4e0aa4
Revert another merge change.
kinyoklion Sep 25, 2024
719dd4b
Merge branch 'main' into rlamb/sdk-710/automatic-start-streaming
kinyoklion Sep 26, 2024
681af9f
Merge branch 'main' into rlamb/sdk-710/automatic-start-streaming
kinyoklion Sep 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 61 additions & 5 deletions packages/sdk/browser/__tests__/BrowserDataManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
off: jest.fn(),
} as unknown as jest.Mocked<FlagManager>;

browserConfig = validateOptions({ streaming: false }, logger);
browserConfig = validateOptions({}, logger);
baseHeaders = {};
emitter = {
emit: jest.fn(),
Expand Down Expand Up @@ -262,7 +262,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

expect(platform.requests.createEventSource).not.toHaveBeenCalled();
dataManager.startDataSource();
dataManager.setForcedStreaming(true);
expect(platform.requests.createEventSource).toHaveBeenCalled();
});

Expand All @@ -277,8 +277,8 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

expect(platform.requests.createEventSource).not.toHaveBeenCalled();
dataManager.startDataSource();
dataManager.startDataSource();
dataManager.setForcedStreaming(true);
dataManager.setForcedStreaming(true);
expect(platform.requests.createEventSource).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledWith(
'[BrowserDataManager] Update processor already active. Not changing state.',
Expand All @@ -287,10 +287,66 @@ describe('given a BrowserDataManager with mocked dependencies', () => {

it('does not start a stream if identify has not been called', async () => {
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
dataManager.startDataSource();
dataManager.setForcedStreaming(true);
expect(platform.requests.createEventSource).not.toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledWith(
'[BrowserDataManager] Context not set, not starting update processor.',
);
});

it('starts a stream on demand when not forced on/off', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

flagManager.loadCached.mockResolvedValue(false);

await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

expect(platform.requests.createEventSource).not.toHaveBeenCalled();
dataManager.setAutomaticStreamingState(true);
expect(platform.requests.createEventSource).toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith('[BrowserDataManager] Starting update processor.');
expect(logger.debug).toHaveBeenCalledWith(
'[BrowserDataManager] Updating streaming state. forced(undefined) automatic(true)',
);
});

it('does not start a stream when forced off', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

dataManager.setForcedStreaming(false);

flagManager.loadCached.mockResolvedValue(false);

await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

expect(platform.requests.createEventSource).not.toHaveBeenCalled();
dataManager.setAutomaticStreamingState(true);
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
'[BrowserDataManager] Updating streaming state. forced(false) automatic(true)',
);
});

it('starts streaming on identify if the automatic state is true', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false };
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

dataManager.setForcedStreaming(undefined);
dataManager.setAutomaticStreamingState(true);
expect(platform.requests.createEventSource).not.toHaveBeenCalled();

flagManager.loadCached.mockResolvedValue(false);

await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions);

expect(platform.requests.createEventSource).toHaveBeenCalled();
});
});
56 changes: 44 additions & 12 deletions packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
AutoEnvAttributes,
base64UrlEncode,
BasicLogger,
LDClient as CommonClient,
Configuration,
createSafeLogger,
Encoding,
FlagManager,
internal,
Expand All @@ -14,6 +14,7 @@ import {
Platform,
} from '@launchdarkly/js-client-sdk-common';
import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common/dist/api/LDIdentifyOptions';
import { EventName } from '@launchdarkly/js-client-sdk-common/dist/LDEmitter';

import BrowserDataManager from './BrowserDataManager';
import GoalManager from './goals/GoalManager';
Expand All @@ -29,24 +30,40 @@ export type LDClient = Omit<
CommonClient,
'setConnectionMode' | 'getConnectionMode' | 'getOffline'
> & {
setStreaming(streaming: boolean): void;
/**
* Specifies whether or not to open a streaming connection to LaunchDarkly for live flag updates.
*
* If this is true, the client will always attempt to maintain a streaming connection; if false,
* it never will. If you leave the value undefined (the default), the client will open a streaming
* connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClient.on}).
*
* This can also be set as the `streaming` property of {@link LDOptions}.
*/
setStreaming(streaming?: boolean): void;
};

export class BrowserClient extends LDClientImpl {
private readonly goalManager?: GoalManager;

constructor(
private readonly clientSideId: string,
autoEnvAttributes: AutoEnvAttributes,
options: BrowserOptions = {},
overridePlatform?: Platform,
) {
const { logger: customLogger, debug } = options;
// Overrides the default logger from the common implementation.
const logger =
customLogger ??
new BasicLogger({
level: debug ? 'debug' : 'info',
createSafeLogger({
// eslint-disable-next-line no-console
debug: debug ? console.debug : () => {},
// eslint-disable-next-line no-console
info: console.info,
// eslint-disable-next-line no-console
destination: console.log,
warn: console.warn,
// eslint-disable-next-line no-console
error: console.error,
});

// TODO: Use the already-configured baseUri from the SDK config. SDK-560
Expand All @@ -59,7 +76,7 @@ export class BrowserClient extends LDClientImpl {
clientSideId,
autoEnvAttributes,
platform,
filterToBaseOptions(options),
filterToBaseOptions({ ...options, logger }),
(
flagManager: FlagManager,
configuration: Configuration,
Expand Down Expand Up @@ -164,12 +181,27 @@ export class BrowserClient extends LDClientImpl {
this.goalManager?.startTracking();
}

setStreaming(streaming: boolean): void {
setStreaming(streaming?: boolean): void {
// With FDv2 we may want to consider if we support connection mode directly.
// Maybe with an extension to connection mode for 'automatic'.
const browserDataManager = this.dataManager as BrowserDataManager;
if (streaming) {
browserDataManager.startDataSource();
} else {
browserDataManager.stopDataSource();
}
browserDataManager.setForcedStreaming(streaming);
}

private updateAutomaticStreamingState() {
const browserDataManager = this.dataManager as BrowserDataManager;
// This will need changed if support for listening to individual flag change
// events it added.
browserDataManager.setAutomaticStreamingState(!!this.emitter.listenerCount('change'));
}

override on(eventName: EventName, listener: Function): void {
super.on(eventName, listener);
this.updateAutomaticStreamingState();
}

override off(eventName: EventName, listener: Function): void {
super.off(eventName, listener);
this.updateAutomaticStreamingState();
}
}
52 changes: 47 additions & 5 deletions packages/sdk/browser/src/BrowserDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ import { ValidatedOptions } from './options';
const logTag = '[BrowserDataManager]';

export default class BrowserDataManager extends BaseDataManager {
// If streaming is forced on or off, then we follow that setting.
// Otherwise we automatically manage streaming state.
private forcedStreaming?: boolean = undefined;
private automaticStreamingState: boolean = false;

// +-----------+-----------+---------------+
// | forced | automatic | state |
// +-----------+-----------+---------------+
// | true | false | streaming |
// | true | true | streaming |
// | false | true | not streaming |
// | false | false | not streaming |
// | undefined | true | streaming |
// | undefined | false | not streaming |
// +-----------+-----------+---------------+

constructor(
platform: Platform,
flagManager: FlagManager,
Expand All @@ -41,6 +57,7 @@ export default class BrowserDataManager extends BaseDataManager {
emitter,
diagnosticsManager,
);
this.forcedStreaming = browserConfig.streaming;
}

private debugLog(message: any, ...args: any[]) {
Expand Down Expand Up @@ -71,17 +88,43 @@ export default class BrowserDataManager extends BaseDataManager {
identifyReject(e);
}

if (this.browserConfig.streaming) {
this.setupConnection(context);
this.updateStreamingState();
}

setForcedStreaming(streaming?: boolean) {
this.forcedStreaming = streaming;
this.updateStreamingState();
}

setAutomaticStreamingState(streaming: boolean) {
this.automaticStreamingState = streaming;
this.updateStreamingState();
}

private updateStreamingState() {
const shouldBeStreaming =
this.forcedStreaming || (this.automaticStreamingState && this.forcedStreaming === undefined);

this.debugLog(
`Updating streaming state. forced(${this.forcedStreaming}) automatic(${this.automaticStreamingState})`,
);

if (shouldBeStreaming) {
this.startDataSource();
} else {
this.stopDataSource();
}
}

stopDataSource() {
private stopDataSource() {
if (this.updateProcessor) {
this.debugLog('Stopping update processor.');
}
this.updateProcessor?.close();
this.updateProcessor = undefined;
}

startDataSource() {
private startDataSource() {
if (this.updateProcessor) {
this.debugLog('Update processor already active. Not changing state.');
return;
Expand Down Expand Up @@ -132,5 +175,4 @@ export default class BrowserDataManager extends BaseDataManager {
const uri = getPollingUri(this.config.serviceEndpoints, path, parameters);
return new Requestor(this.platform.requests, uri, headers, method, body);
}
// TODO: Automatically start streaming if event handlers are registered.
}
4 changes: 3 additions & 1 deletion packages/shared/sdk-client/__tests__/LDClientImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ describe('sdk-client object', () => {
const carContext2: LDContext = { kind: 'car', key: 'test-car-2' };
await ldc.identify(carContext2);

expect(emitter.listenerCount('change')).toEqual(1);
// No default listeners. This is important for clients to be able to determine if there are
// any listeners and act on that information.
expect(emitter.listenerCount('change')).toEqual(0);
expect(emitter.listenerCount('error')).toEqual(1);
});

Expand Down
6 changes: 2 additions & 4 deletions packages/shared/sdk-client/src/LDClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default class LDClientImpl implements LDClient {

private eventFactoryDefault = new EventFactory(false);
private eventFactoryWithReasons = new EventFactory(true);
private emitter: LDEmitter;
protected emitter: LDEmitter;
private flagManager: FlagManager;

private eventSendingEnabled: boolean = false;
Expand Down Expand Up @@ -105,15 +105,13 @@ export default class LDClientImpl implements LDClient {
this.diagnosticsManager,
);
this.emitter = new LDEmitter();
this.emitter.on('change', (c: LDContext, changedKeys: string[]) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left the default err catcher for now, but we may need to remove it in the future.

The old emitter had this functionality:

  emitter.maybeReportError = function(error) {
    if (!error) {
      return;
    }
    if (listeningTo('error')) {
      this.emit('error', error);
    } else {
      (logger || console).error(error.message);
    }
  };

Basically if you were listening for errors, then you get to handle them. If you were not listening to errors, then it would log them.

Which is potentially useful functionality to have, but out of the scope of this PR.

this.logger.debug(`change: context: ${JSON.stringify(c)}, flags: ${changedKeys}`);
});
this.emitter.on('error', (c: LDContext, err: any) => {
this.logger.error(`error: ${err}, context: ${JSON.stringify(c)}`);
});

this.flagManager.on((context, flagKeys) => {
const ldContext = Context.toLDContext(context);
this.logger.debug(`change: context: ${JSON.stringify(ldContext)}, flags: ${flagKeys}`);
this.emitter.emit('change', ldContext, flagKeys);
});

Expand Down
6 changes: 6 additions & 0 deletions packages/shared/sdk-client/src/LDEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { LDLogger } from '@launchdarkly/js-sdk-common';

export type EventName = 'error' | 'change';

/**
* Implementation Note: There should not be any default listeners for change events in a client
* implementation. Default listeners mean a client cannot determine when there are actual
* application developer provided listeners. If we require default listeners, then we should add
* a system to allow listeners which have counts independent of the primary listener counts.
*/
export default class LDEmitter {
private listeners: Map<EventName, Function[]> = new Map();

Expand Down
Loading