Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add bootstrap support. #600

Merged
merged 15 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions packages/sdk/browser/__tests__/BrowserClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {

import { BrowserClient } from '../src/BrowserClient';
import { MockHasher } from './MockHasher';
import { goodBootstrapDataWithReasons } from './testBootstrapData';

function mockResponse(value: string, statusCode: number) {
const response: Response = {
Expand Down Expand Up @@ -257,4 +258,31 @@ describe('given a mock platform for a BrowserClient', () => {
url: 'http://filtered.com',
});
});

it('can use bootstrap data', async () => {
const client = new BrowserClient(
'client-side-id',
AutoEnvAttributes.Disabled,
{
streaming: false,
logger,
diagnosticOptOut: true,
},
platform,
);
await client.identify(
{ kind: 'user', key: 'bob' },
{
bootstrap: goodBootstrapDataWithReasons,
},
);

expect(client.jsonVariationDetail('json', undefined)).toEqual({
reason: {
kind: 'OFF',
},
value: ['a', 'b', 'c', 'd'],
variationIndex: 1,
});
});
});
32 changes: 32 additions & 0 deletions packages/sdk/browser/__tests__/BrowserDataManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import BrowserEncoding from '../src/platform/BrowserEncoding';
import BrowserInfo from '../src/platform/BrowserInfo';
import LocalStorage from '../src/platform/LocalStorage';
import { MockHasher } from './MockHasher';
import { goodBootstrapData } from './testBootstrapData';

global.TextEncoder = TextEncoder;

Expand Down Expand Up @@ -123,6 +124,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
upsert: jest.fn(),
on: jest.fn(),
off: jest.fn(),
setBootstrap: jest.fn(),
} as unknown as jest.Mocked<FlagManager>;

browserConfig = validateOptions({}, logger);
Expand Down Expand Up @@ -314,6 +316,36 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
});

it('uses data from bootstrap and does not make an initial poll', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
const identifyOptions: BrowserIdentifyOptions = {
bootstrap: goodBootstrapData,
};
const identifyResolve = jest.fn();
const identifyReject = jest.fn();

flagManager.loadCached.mockResolvedValue(true);

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

expect(logger.debug).toHaveBeenCalledWith(
'[BrowserDataManager] Identify - Initialization completed from bootstrap',
);

expect(flagManager.loadCached).not.toHaveBeenCalledWith(context);
expect(identifyResolve).toHaveBeenCalled();
expect(flagManager.init).not.toHaveBeenCalled();
expect(flagManager.setBootstrap).toHaveBeenCalledWith(expect.anything(), {
cat: { version: 2, flag: { version: 2, variation: 1, value: false } },
json: { version: 3, flag: { version: 3, variation: 1, value: ['a', 'b', 'c', 'd'] } },
killswitch: { version: 5, flag: { version: 5, variation: 0, value: true } },
'my-boolean-flag': { version: 11, flag: { version: 11, variation: 1, value: false } },
'string-flag': { version: 3, flag: { version: 3, variation: 1, value: 'is bob' } },
});
expect(platform.requests.createEventSource).not.toHaveBeenCalled();
expect(platform.requests.fetch).not.toHaveBeenCalled();
});

it('should identify from polling when there are no cached flags', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });

Expand Down
149 changes: 149 additions & 0 deletions packages/sdk/browser/__tests__/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { jest } from '@jest/globals';

import { readFlagsFromBootstrap } from '../src/bootstrap';
import { goodBootstrapData, goodBootstrapDataWithReasons } from './testBootstrapData';

it('can read valid bootstrap data', () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};

const readData = readFlagsFromBootstrap(logger, goodBootstrapData);
expect(readData).toEqual({
cat: { version: 2, flag: { version: 2, variation: 1, value: false } },
json: { version: 3, flag: { version: 3, variation: 1, value: ['a', 'b', 'c', 'd'] } },
killswitch: { version: 5, flag: { version: 5, variation: 0, value: true } },
'my-boolean-flag': { version: 11, flag: { version: 11, variation: 1, value: false } },
'string-flag': { version: 3, flag: { version: 3, variation: 1, value: 'is bob' } },
});
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled();
});

it('can read valid bootstrap data with reasons', () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};

const readData = readFlagsFromBootstrap(logger, goodBootstrapDataWithReasons);
expect(readData).toEqual({
cat: {
version: 2,
flag: {
version: 2,
variation: 1,
value: false,
reason: {
kind: 'OFF',
},
},
},
json: {
version: 3,
flag: {
version: 3,
variation: 1,
value: ['a', 'b', 'c', 'd'],
reason: {
kind: 'OFF',
},
},
},
killswitch: {
version: 5,
flag: {
version: 5,
variation: 0,
value: true,
reason: {
kind: 'FALLTHROUGH',
},
},
},
'my-boolean-flag': {
version: 11,
flag: {
version: 11,
variation: 1,
value: false,
reason: {
kind: 'OFF',
},
},
},
'string-flag': {
version: 3,
flag: {
version: 3,
variation: 1,
value: 'is bob',
reason: {
kind: 'OFF',
},
},
},
});
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled();
});

it('can read old bootstrap data', () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};

const oldData: any = { ...goodBootstrapData };
delete oldData.$flagsState;

const readData = readFlagsFromBootstrap(logger, oldData);
expect(readData).toEqual({
cat: { version: 0, flag: { version: 0, value: false } },
json: { version: 0, flag: { version: 0, value: ['a', 'b', 'c', 'd'] } },
killswitch: { version: 0, flag: { version: 0, value: true } },
'my-boolean-flag': { version: 0, flag: { version: 0, value: false } },
'string-flag': { version: 0, flag: { version: 0, value: 'is bob' } },
});
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'LaunchDarkly client was initialized with bootstrap data that did not' +
' include flag metadata. Events may not be sent correctly.',
);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.error).not.toHaveBeenCalled();
});

it('can handle invalid bootstrap data', () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};

const invalid: any = { $valid: false, $flagsState: {} };

const readData = readFlagsFromBootstrap(logger, invalid);
expect(readData).toEqual({});
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'LaunchDarkly bootstrap data is not available because the back end' +
' could not read the flags.',
);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.error).not.toHaveBeenCalled();
});
76 changes: 76 additions & 0 deletions packages/sdk/browser/__tests__/testBootstrapData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export const goodBootstrapData = {
cat: false,
json: ['a', 'b', 'c', 'd'],
killswitch: true,
'my-boolean-flag': false,
'string-flag': 'is bob',
$flagsState: {
cat: {
variation: 1,
version: 2,
},
json: {
variation: 1,
version: 3,
},
killswitch: {
variation: 0,
version: 5,
},
'my-boolean-flag': {
variation: 1,
version: 11,
},
'string-flag': {
variation: 1,
version: 3,
},
},
$valid: true,
};

export const goodBootstrapDataWithReasons = {
cat: false,
json: ['a', 'b', 'c', 'd'],
killswitch: true,
'my-boolean-flag': false,
'string-flag': 'is bob',
$flagsState: {
cat: {
variation: 1,
version: 2,
reason: {
kind: 'OFF',
},
},
json: {
variation: 1,
version: 3,
reason: {
kind: 'OFF',
},
},
killswitch: {
variation: 0,
version: 5,
reason: {
kind: 'FALLTHROUGH',
},
},
'my-boolean-flag': {
variation: 1,
version: 11,
reason: {
kind: 'OFF',
},
},
'string-flag': {
variation: 1,
version: 3,
reason: {
kind: 'OFF',
},
},
},
$valid: true,
};
34 changes: 28 additions & 6 deletions packages/sdk/browser/src/BrowserDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Requestor,
} from '@launchdarkly/js-client-sdk-common';

import { readFlagsFromBootstrap } from './bootstrap';
import { BrowserIdentifyOptions } from './BrowserIdentifyOptions';
import { ValidatedOptions } from './options';

Expand Down Expand Up @@ -84,14 +85,27 @@ export default class BrowserDataManager extends BaseDataManager {
this.setConnectionParams();
}
this.secureModeHash = browserIdentifyOptions?.hash;
if (await this.flagManager.loadCached(context)) {
this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.');

if (browserIdentifyOptions?.bootstrap) {
this.finishIdentifyFromBootstrap(context, browserIdentifyOptions.bootstrap, identifyResolve);
} else {
if (await this.flagManager.loadCached(context)) {
this.debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.');
}
const plainContextString = JSON.stringify(Context.toLDContext(context));
const requestor = this.getRequestor(plainContextString);
await this.finishIdentifyFromPoll(requestor, context, identifyResolve, identifyReject);
}
const plainContextString = JSON.stringify(Context.toLDContext(context));
const requestor = this.getRequestor(plainContextString);

// TODO: Handle wait for network results in a meaningful way. SDK-707
this.updateStreamingState();
}

private async finishIdentifyFromPoll(
requestor: Requestor,
context: Context,
identifyResolve: () => void,
identifyReject: (err: Error) => void,
) {
try {
this.dataSourceStatusManager.requestStateUpdate(DataSourceState.Initializing);
const payload = await requestor.requestPayload();
Expand All @@ -113,8 +127,16 @@ export default class BrowserDataManager extends BaseDataManager {
);
identifyReject(e);
}
}

this.updateStreamingState();
private finishIdentifyFromBootstrap(
context: Context,
bootstrap: unknown,
identifyResolve: () => void,
) {
this.flagManager.setBootstrap(context, readFlagsFromBootstrap(this.logger, bootstrap));
this.debugLog('Identify - Initialization completed from bootstrap');
identifyResolve();
}

setForcedStreaming(streaming?: boolean) {
Expand Down
15 changes: 15 additions & 0 deletions packages/sdk/browser/src/BrowserIdentifyOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,19 @@ export interface BrowserIdentifyOptions extends Omit<LDIdentifyOptions, 'waitFor
* (https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk).
*/
hash?: string;

/**
* The initial set of flags to use until the remote set is retrieved.
*
* Bootstrap data can be generated by server SDKs. When bootstrap data is provided the SDK the
* identification operation will complete without waiting for any values from LaunchDarkly and
* the variation calls can be used immediately.
*
* If streaming is activated, either it is configured to always be used, or is activated
* via setStreaming, or via the addition of change handlers, then a streaming connection will
* subsequently be established.
*
* For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/features/bootstrapping#javascript).
*/
bootstrap?: unknown;
}
Loading
Loading