Skip to content

Commit

Permalink
feat: Add bootstrap support. (#600)
Browse files Browse the repository at this point in the history
Adds bootstrap support.

Doesn't make any changes we may need to manage using
localStorage+boostrap, which wasn't possible with the old SDK, but is
possible now.
  • Loading branch information
kinyoklion authored Oct 3, 2024
1 parent 8cd0cdc commit 4e5dbee
Show file tree
Hide file tree
Showing 11 changed files with 393 additions and 10 deletions.
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

0 comments on commit 4e5dbee

Please sign in to comment.