From a12ac4f04666737157b947041b05b2117b268726 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 9 Oct 2018 17:00:18 -0700 Subject: [PATCH 1/5] move entire client implementation into common package --- packages/ldclient-js-common/jest.config.js | 16 + packages/ldclient-js-common/package-lock.json | 8 +- packages/ldclient-js-common/package.json | 5 +- .../src/EventEmitter.js | 0 .../src/EventProcessor.js | 5 +- .../src/EventSender.js | 0 .../src/EventSummarizer.js | 0 .../src/GoalTracker.js | 0 .../src/Identity.js | 0 .../src/Requestor.js | 5 +- .../src/Store.js | 7 +- .../src/Stream.js | 0 .../src/UserFilter.js | 7 +- .../src/__mocks__/Requestor.js | 0 .../src/__tests__/.eslintrc.yaml | 0 .../src/__tests__/EventProcessor-test.js | 0 .../src/__tests__/EventSender-test.js | 0 .../src/__tests__/EventSource-mock.js | 0 .../src/__tests__/EventSummarizer-test.js | 0 .../src/__tests__/LDClient-events-test.js | 0 .../src/__tests__/LDClient-streaming-test.js | 0 .../src/__tests__/LDClient-test.js | 20 +- .../src/__tests__/Requestor-test.js | 0 .../src/__tests__/Store-test.js | 7 +- .../src/__tests__/Stream-test.js | 0 .../src/__tests__/UserFilter-test.js | 0 .../src/__tests__/utils-test.js | 0 .../src/errors.js | 0 packages/ldclient-js-common/src/index.js | 648 +++++++++++++++++- packages/ldclient-js-common/src/jest.setup.js | 1 + .../src/utils.js | 0 packages/ldclient-js/src/index.js | 641 +---------------- 32 files changed, 699 insertions(+), 671 deletions(-) create mode 100644 packages/ldclient-js-common/jest.config.js rename packages/{ldclient-js => ldclient-js-common}/src/EventEmitter.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/EventProcessor.js (96%) rename packages/{ldclient-js => ldclient-js-common}/src/EventSender.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/EventSummarizer.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/GoalTracker.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/Identity.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/Requestor.js (95%) rename packages/{ldclient-js => ldclient-js-common}/src/Store.js (87%) rename packages/{ldclient-js => ldclient-js-common}/src/Stream.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/UserFilter.js (91%) rename packages/{ldclient-js => ldclient-js-common}/src/__mocks__/Requestor.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/.eslintrc.yaml (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/EventProcessor-test.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/EventSender-test.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/EventSource-mock.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/EventSummarizer-test.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/LDClient-events-test.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/LDClient-streaming-test.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/LDClient-test.js (96%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/Requestor-test.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/Store-test.js (81%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/Stream-test.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/UserFilter-test.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/__tests__/utils-test.js (100%) rename packages/{ldclient-js => ldclient-js-common}/src/errors.js (100%) create mode 100644 packages/ldclient-js-common/src/jest.setup.js rename packages/{ldclient-js => ldclient-js-common}/src/utils.js (100%) diff --git a/packages/ldclient-js-common/jest.config.js b/packages/ldclient-js-common/jest.config.js new file mode 100644 index 00000000..b091646b --- /dev/null +++ b/packages/ldclient-js-common/jest.config.js @@ -0,0 +1,16 @@ +const version = process.env.npm_package_version; + +module.exports = { + automock: false, + resetModules: true, + rootDir: 'src', + setupFiles: ['jest-localstorage-mock', './jest.setup.js'], + testMatch: ['**/__tests__/**/*-test.js'], + transform: { + '^.+\\.js$': 'babel-jest', + }, + globals: { + window: true, + VERSION: version, + }, +}; diff --git a/packages/ldclient-js-common/package-lock.json b/packages/ldclient-js-common/package-lock.json index 2d6010e5..c0811fd1 100644 --- a/packages/ldclient-js-common/package-lock.json +++ b/packages/ldclient-js-common/package-lock.json @@ -220,6 +220,11 @@ "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", "dev": true }, + "Base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/Base64/-/Base64-1.0.1.tgz", + "integrity": "sha1-3vRcxQyWG8yb8jIdD1K8v+wfG7E=" + }, "abab": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", @@ -2771,8 +2776,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { "version": "1.11.0", diff --git a/packages/ldclient-js-common/package.json b/packages/ldclient-js-common/package.json index 013a0dff..36f69b0e 100644 --- a/packages/ldclient-js-common/package.json +++ b/packages/ldclient-js-common/package.json @@ -72,7 +72,10 @@ "sinon": "4.5.0", "typescript": "3.0.1" }, - "dependencies": {}, + "dependencies": { + "Base64": "1.0.1", + "escape-string-regexp": "1.0.5" + }, "repository": { "type": "git", "url": "git://github.com/launchdarkly/js-client.git" diff --git a/packages/ldclient-js/src/EventEmitter.js b/packages/ldclient-js-common/src/EventEmitter.js similarity index 100% rename from packages/ldclient-js/src/EventEmitter.js rename to packages/ldclient-js-common/src/EventEmitter.js diff --git a/packages/ldclient-js/src/EventProcessor.js b/packages/ldclient-js-common/src/EventProcessor.js similarity index 96% rename from packages/ldclient-js/src/EventProcessor.js rename to packages/ldclient-js-common/src/EventProcessor.js index 30b74e48..155a3205 100644 --- a/packages/ldclient-js/src/EventProcessor.js +++ b/packages/ldclient-js-common/src/EventProcessor.js @@ -1,9 +1,8 @@ -import * as common from 'ldclient-js-common'; - import EventSender from './EventSender'; import EventSummarizer from './EventSummarizer'; import UserFilter from './UserFilter'; import * as errors from './errors'; +import * as messages from './messages'; import * as utils from './utils'; export default function EventProcessor(eventsUrl, environmentId, options = {}, emitter = null, sender = null) { @@ -131,7 +130,7 @@ export default function EventProcessor(eventsUrl, environmentId, options = {}, e utils.onNextTick(() => { emitter.maybeReportError( new errors.LDUnexpectedResponseError( - common.messages.httpErrorMessage(responseInfo.status, 'event posting', 'some events were dropped') + messages.httpErrorMessage(responseInfo.status, 'event posting', 'some events were dropped') ) ); }); diff --git a/packages/ldclient-js/src/EventSender.js b/packages/ldclient-js-common/src/EventSender.js similarity index 100% rename from packages/ldclient-js/src/EventSender.js rename to packages/ldclient-js-common/src/EventSender.js diff --git a/packages/ldclient-js/src/EventSummarizer.js b/packages/ldclient-js-common/src/EventSummarizer.js similarity index 100% rename from packages/ldclient-js/src/EventSummarizer.js rename to packages/ldclient-js-common/src/EventSummarizer.js diff --git a/packages/ldclient-js/src/GoalTracker.js b/packages/ldclient-js-common/src/GoalTracker.js similarity index 100% rename from packages/ldclient-js/src/GoalTracker.js rename to packages/ldclient-js-common/src/GoalTracker.js diff --git a/packages/ldclient-js/src/Identity.js b/packages/ldclient-js-common/src/Identity.js similarity index 100% rename from packages/ldclient-js/src/Identity.js rename to packages/ldclient-js-common/src/Identity.js diff --git a/packages/ldclient-js/src/Requestor.js b/packages/ldclient-js-common/src/Requestor.js similarity index 95% rename from packages/ldclient-js/src/Requestor.js rename to packages/ldclient-js-common/src/Requestor.js index cfe03369..1ca30e6f 100644 --- a/packages/ldclient-js/src/Requestor.js +++ b/packages/ldclient-js-common/src/Requestor.js @@ -1,7 +1,6 @@ -import * as common from 'ldclient-js-common'; - import * as utils from './utils'; import * as errors from './errors'; +import * as messages from './messages'; const json = 'application/json'; @@ -44,7 +43,7 @@ function fetchJSON(endpoint, body, callback, sendLDHeaders) { function getResponseError(xhr) { if (xhr.status === 404) { - return new errors.LDInvalidEnvironmentIdError(common.messages.environmentNotFound()); + return new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound()); } else { return xhr.statusText; } diff --git a/packages/ldclient-js/src/Store.js b/packages/ldclient-js-common/src/Store.js similarity index 87% rename from packages/ldclient-js/src/Store.js rename to packages/ldclient-js-common/src/Store.js index b834dce3..4a7cde19 100644 --- a/packages/ldclient-js/src/Store.js +++ b/packages/ldclient-js-common/src/Store.js @@ -1,5 +1,4 @@ -import * as common from 'ldclient-js-common'; - +import * as messages from './messages'; import * as utils from './utils'; export default function Store(environment, hash, ident) { @@ -20,7 +19,7 @@ export default function Store(environment, hash, ident) { try { dataStr = localStorage.getItem(key); } catch (ex) { - console.warn(common.messages.localStorageUnavailable()); + console.warn(messages.localStorageUnavailable()); return null; } try { @@ -44,7 +43,7 @@ export default function Store(environment, hash, ident) { try { localStorage.setItem(key, JSON.stringify(data)); } catch (ex) { - console.warn(common.messages.localStorageUnavailable()); + console.warn(messages.localStorageUnavailable()); } }; diff --git a/packages/ldclient-js/src/Stream.js b/packages/ldclient-js-common/src/Stream.js similarity index 100% rename from packages/ldclient-js/src/Stream.js rename to packages/ldclient-js-common/src/Stream.js diff --git a/packages/ldclient-js/src/UserFilter.js b/packages/ldclient-js-common/src/UserFilter.js similarity index 91% rename from packages/ldclient-js/src/UserFilter.js rename to packages/ldclient-js-common/src/UserFilter.js index 23b8e886..d3ca4513 100644 --- a/packages/ldclient-js/src/UserFilter.js +++ b/packages/ldclient-js-common/src/UserFilter.js @@ -1,5 +1,4 @@ -import * as common from 'ldclient-js-common'; - +import * as messages from './messages'; import * as utils from './utils'; /** @@ -32,12 +31,12 @@ export default function UserFilter(config) { if (config.all_attributes_private !== undefined) { console && console.warn && - console.warn(common.messages.deprecated('all_attributes_private', 'allAttributesPrivate')); + console.warn(messages.deprecated('all_attributes_private', 'allAttributesPrivate')); } if (config.private_attribute_names !== undefined) { console && console.warn && - console.warn(common.messages.deprecated('private_attribute_names', 'privateAttributeNames')); + console.warn(messages.deprecated('private_attribute_names', 'privateAttributeNames')); } filter.filterUser = function(user) { diff --git a/packages/ldclient-js/src/__mocks__/Requestor.js b/packages/ldclient-js-common/src/__mocks__/Requestor.js similarity index 100% rename from packages/ldclient-js/src/__mocks__/Requestor.js rename to packages/ldclient-js-common/src/__mocks__/Requestor.js diff --git a/packages/ldclient-js/src/__tests__/.eslintrc.yaml b/packages/ldclient-js-common/src/__tests__/.eslintrc.yaml similarity index 100% rename from packages/ldclient-js/src/__tests__/.eslintrc.yaml rename to packages/ldclient-js-common/src/__tests__/.eslintrc.yaml diff --git a/packages/ldclient-js/src/__tests__/EventProcessor-test.js b/packages/ldclient-js-common/src/__tests__/EventProcessor-test.js similarity index 100% rename from packages/ldclient-js/src/__tests__/EventProcessor-test.js rename to packages/ldclient-js-common/src/__tests__/EventProcessor-test.js diff --git a/packages/ldclient-js/src/__tests__/EventSender-test.js b/packages/ldclient-js-common/src/__tests__/EventSender-test.js similarity index 100% rename from packages/ldclient-js/src/__tests__/EventSender-test.js rename to packages/ldclient-js-common/src/__tests__/EventSender-test.js diff --git a/packages/ldclient-js/src/__tests__/EventSource-mock.js b/packages/ldclient-js-common/src/__tests__/EventSource-mock.js similarity index 100% rename from packages/ldclient-js/src/__tests__/EventSource-mock.js rename to packages/ldclient-js-common/src/__tests__/EventSource-mock.js diff --git a/packages/ldclient-js/src/__tests__/EventSummarizer-test.js b/packages/ldclient-js-common/src/__tests__/EventSummarizer-test.js similarity index 100% rename from packages/ldclient-js/src/__tests__/EventSummarizer-test.js rename to packages/ldclient-js-common/src/__tests__/EventSummarizer-test.js diff --git a/packages/ldclient-js/src/__tests__/LDClient-events-test.js b/packages/ldclient-js-common/src/__tests__/LDClient-events-test.js similarity index 100% rename from packages/ldclient-js/src/__tests__/LDClient-events-test.js rename to packages/ldclient-js-common/src/__tests__/LDClient-events-test.js diff --git a/packages/ldclient-js/src/__tests__/LDClient-streaming-test.js b/packages/ldclient-js-common/src/__tests__/LDClient-streaming-test.js similarity index 100% rename from packages/ldclient-js/src/__tests__/LDClient-streaming-test.js rename to packages/ldclient-js-common/src/__tests__/LDClient-streaming-test.js diff --git a/packages/ldclient-js/src/__tests__/LDClient-test.js b/packages/ldclient-js-common/src/__tests__/LDClient-test.js similarity index 96% rename from packages/ldclient-js/src/__tests__/LDClient-test.js rename to packages/ldclient-js-common/src/__tests__/LDClient-test.js index b6ffcf1d..bcf02833 100644 --- a/packages/ldclient-js/src/__tests__/LDClient-test.js +++ b/packages/ldclient-js-common/src/__tests__/LDClient-test.js @@ -1,8 +1,8 @@ import sinon from 'sinon'; import semverCompare from 'semver-compare'; -import * as common from 'ldclient-js-common'; import * as LDClient from '../index'; +import * as messages from '../messages'; import * as utils from '../utils'; describe('LDClient', () => { @@ -85,7 +85,7 @@ describe('LDClient', () => { bootstrap: {}, }); client.on('error', err => { - expect(err.message).toEqual(common.messages.environmentNotSpecified()); + expect(err.message).toEqual(messages.environmentNotSpecified()); done(); }); }); @@ -93,7 +93,7 @@ describe('LDClient', () => { it('should emit an error when an invalid environment key is specified', done => { const client = LDClient.initialize('abc', user); client.on('error', err => { - expect(err.message).toEqual('Error fetching flag settings: ' + common.messages.environmentNotFound()); + expect(err.message).toEqual('Error fetching flag settings: ' + messages.environmentNotFound()); done(); }); client.waitForInitialization().catch(() => {}); // jest doesn't like unhandled rejections @@ -103,7 +103,7 @@ describe('LDClient', () => { it('should emit a failure event when an invalid environment key is specified', done => { const client = LDClient.initialize('abc', user); client.on('failed', err => { - expect(err.message).toEqual('Error fetching flag settings: ' + common.messages.environmentNotFound()); + expect(err.message).toEqual('Error fetching flag settings: ' + messages.environmentNotFound()); done(); }); client.waitForInitialization().catch(() => {}); @@ -160,7 +160,7 @@ describe('LDClient', () => { bootstrap: { foo: 'bar' }, }); - expect(warnSpy).toHaveBeenCalledWith(common.messages.bootstrapOldFormat()); + expect(warnSpy).toHaveBeenCalledWith(messages.bootstrapOldFormat()); }); it('does not log warning when bootstrap object uses new format', () => { @@ -248,7 +248,7 @@ describe('LDClient', () => { }); client.on('ready', () => { - expect(warnSpy).toHaveBeenCalledWith(common.messages.localStorageUnavailable()); + expect(warnSpy).toHaveBeenCalledWith(messages.localStorageUnavailable()); done(); }); @@ -267,7 +267,7 @@ describe('LDClient', () => { requests[0].respond(200, { 'Content-Type': 'application/json' }, '[{"key": "known", "kind": "custom"}]'); client.on('ready', () => { - expect(warnSpy).toHaveBeenCalledWith(common.messages.localStorageUnavailable()); + expect(warnSpy).toHaveBeenCalledWith(messages.localStorageUnavailable()); done(); }); }); @@ -361,7 +361,7 @@ describe('LDClient', () => { const badCustomEventKeys = [123, [], {}, null, undefined]; badCustomEventKeys.forEach(key => { client.track(key); - expect(errorSpy).toHaveBeenCalledWith(common.messages.unknownCustomEventKey(key)); + expect(errorSpy).toHaveBeenCalledWith(messages.unknownCustomEventKey(key)); }); done(); }); @@ -376,7 +376,7 @@ describe('LDClient', () => { client.on('ready', () => { client.track('unknown'); - expect(warnSpy).toHaveBeenCalledWith(common.messages.unknownCustomEventKey('unknown')); + expect(warnSpy).toHaveBeenCalledWith(messages.unknownCustomEventKey('unknown')); done(); }); }); @@ -508,7 +508,7 @@ describe('LDClient', () => { it('rejects promise if flags request fails', done => { const client = LDClient.initialize('abc', user); client.waitForInitialization().catch(err => { - expect(err.message).toEqual('Error fetching flag settings: ' + common.messages.environmentNotFound()); + expect(err.message).toEqual('Error fetching flag settings: ' + messages.environmentNotFound()); done(); }); requests[0].respond(404); diff --git a/packages/ldclient-js/src/__tests__/Requestor-test.js b/packages/ldclient-js-common/src/__tests__/Requestor-test.js similarity index 100% rename from packages/ldclient-js/src/__tests__/Requestor-test.js rename to packages/ldclient-js-common/src/__tests__/Requestor-test.js diff --git a/packages/ldclient-js/src/__tests__/Store-test.js b/packages/ldclient-js-common/src/__tests__/Store-test.js similarity index 81% rename from packages/ldclient-js/src/__tests__/Store-test.js rename to packages/ldclient-js-common/src/__tests__/Store-test.js index 0f12192a..ca0aeabd 100644 --- a/packages/ldclient-js/src/__tests__/Store-test.js +++ b/packages/ldclient-js-common/src/__tests__/Store-test.js @@ -1,5 +1,4 @@ -import * as common from 'ldclient-js-common'; - +import * as messages from '../messages'; import Identity from '../Identity'; import Store from '../Store'; @@ -15,7 +14,7 @@ describe('Store', () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); store.loadFlags(); - expect(consoleWarnSpy).toHaveBeenCalledWith(common.messages.localStorageUnavailable()); + expect(consoleWarnSpy).toHaveBeenCalledWith(messages.localStorageUnavailable()); consoleWarnSpy.mockRestore(); getItemSpy.mockRestore(); @@ -30,7 +29,7 @@ describe('Store', () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); store.saveFlags({ foo: {} }); - expect(consoleWarnSpy).toHaveBeenCalledWith(common.messages.localStorageUnavailable()); + expect(consoleWarnSpy).toHaveBeenCalledWith(messages.localStorageUnavailable()); consoleWarnSpy.mockRestore(); setItemSpy.mockRestore(); diff --git a/packages/ldclient-js/src/__tests__/Stream-test.js b/packages/ldclient-js-common/src/__tests__/Stream-test.js similarity index 100% rename from packages/ldclient-js/src/__tests__/Stream-test.js rename to packages/ldclient-js-common/src/__tests__/Stream-test.js diff --git a/packages/ldclient-js/src/__tests__/UserFilter-test.js b/packages/ldclient-js-common/src/__tests__/UserFilter-test.js similarity index 100% rename from packages/ldclient-js/src/__tests__/UserFilter-test.js rename to packages/ldclient-js-common/src/__tests__/UserFilter-test.js diff --git a/packages/ldclient-js/src/__tests__/utils-test.js b/packages/ldclient-js-common/src/__tests__/utils-test.js similarity index 100% rename from packages/ldclient-js/src/__tests__/utils-test.js rename to packages/ldclient-js-common/src/__tests__/utils-test.js diff --git a/packages/ldclient-js/src/errors.js b/packages/ldclient-js-common/src/errors.js similarity index 100% rename from packages/ldclient-js/src/errors.js rename to packages/ldclient-js-common/src/errors.js diff --git a/packages/ldclient-js-common/src/index.js b/packages/ldclient-js-common/src/index.js index 0a125f95..719cb20c 100644 --- a/packages/ldclient-js-common/src/index.js +++ b/packages/ldclient-js-common/src/index.js @@ -1,3 +1,649 @@ +import EventProcessor from './EventProcessor'; +import EventEmitter from './EventEmitter'; +import GoalTracker from './GoalTracker'; +import Store from './Store'; +import Stream from './Stream'; +import Requestor from './Requestor'; +import Identity from './Identity'; +import * as utils from './utils'; +import * as errors from './errors'; import * as messages from './messages'; -exports.messages = messages; +const readyEvent = 'ready'; +const successEvent = 'initialized'; +const failedEvent = 'failed'; +const changeEvent = 'change'; +const goalsEvent = 'goalsReady'; +const locationWatcherInterval = 300; + +export function initialize(env, user, options = {}) { + const baseUrl = options.baseUrl || 'https://app.launchdarkly.com'; + const eventsUrl = options.eventsUrl || 'https://events.launchdarkly.com'; + const streamUrl = options.streamUrl || 'https://clientstream.launchdarkly.com'; + const hash = options.hash; + const sendEvents = optionWithDefault('sendEvents', true); + const sendLDHeaders = optionWithDefault('sendLDHeaders', true); + const allowFrequentDuplicateEvents = !!options.allowFrequentDuplicateEvents; + const sendEventsOnlyForVariation = !!options.sendEventsOnlyForVariation; + const fetchGoals = typeof options.fetchGoals === 'undefined' ? true : options.fetchGoals; + const environment = env; + const emitter = EventEmitter(); + const stream = Stream(streamUrl, environment, hash, options); + const events = options.eventProcessor || EventProcessor(eventsUrl, environment, options, emitter); + const requestor = Requestor(baseUrl, environment, options.useReport, options.evaluationReasons, sendLDHeaders); + const seenRequests = {}; + let flags = typeof options.bootstrap === 'object' ? readFlagsFromBootstrap(options.bootstrap) : {}; + let goalTracker; + let useLocalStorage; + let goals; + let subscribedToChangeEvents; + let firstEvent = true; + + function optionWithDefault(name, defaultVal) { + return typeof options[name] === 'undefined' ? defaultVal : options[name]; + } + + function readFlagsFromBootstrap(data) { + // If the bootstrap data came from an older server-side SDK, we'll have just a map of keys to values. + // Newer SDKs that have an allFlagsState method will provide an extra "$flagsState" key that contains + // the rest of the metadata we want. We do it this way for backward compatibility with older JS SDKs. + const keys = Object.keys(data); + const metadataKey = '$flagsState'; + const validKey = '$valid'; + const metadata = data[metadataKey]; + if (!metadata && keys.length) { + console.warn(messages.bootstrapOldFormat()); + } + if (data[validKey] === false) { + console.warn(messages.bootstrapInvalid()); + } + const ret = {}; + keys.forEach(key => { + if (key !== metadataKey && key !== validKey) { + let flag = { value: data[key] }; + if (metadata && metadata[key]) { + flag = utils.extend(flag, metadata[key]); + } else { + flag.version = 0; + } + ret[key] = flag; + } + }); + return ret; + } + + function shouldEnqueueEvent() { + return sendEvents && !doNotTrack(); + } + + function enqueueEvent(event) { + if (!event.user) { + if (firstEvent) { + if (console && console.warn) { + console.warn( + 'Be sure to call `identify` in the LaunchDarkly client: http://docs.launchdarkly.com/docs/running-an-ab-test#include-the-client-side-snippet' + ); + } + firstEvent = false; + } + return; + } + firstEvent = false; + if (shouldEnqueueEvent()) { + events.enqueue(event); + } + } + + function sendIdentifyEvent(user) { + if (user) { + enqueueEvent({ + kind: 'identify', + key: user.key, + user: user, + creationDate: new Date().getTime(), + }); + } + } + + const ident = Identity(user, sendIdentifyEvent); + const store = Store(environment, hash, ident); + + function sendFlagEvent(key, detail, defaultValue) { + const user = ident.getUser(); + const now = new Date(); + const value = detail ? detail.value : null; + if (!allowFrequentDuplicateEvents) { + const cacheKey = JSON.stringify(value) + (user && user.key ? user.key : '') + key; // see below + const cached = seenRequests[cacheKey]; + // cache TTL is five minutes + if (cached && now - cached < 300000) { + return; + } + seenRequests[cacheKey] = now; + } + + const event = { + kind: 'feature', + key: key, + user: user, + value: value, + variation: detail ? detail.variationIndex : null, + default: defaultValue, + creationDate: now.getTime(), + reason: detail ? detail.reason : null, + }; + const flag = flags[key]; + if (flag) { + event.version = flag.flagVersion ? flag.flagVersion : flag.version; + event.trackEvents = flag.trackEvents; + event.debugEventsUntilDate = flag.debugEventsUntilDate; + } + + enqueueEvent(event); + } + + function sendGoalEvent(kind, goal) { + const event = { + kind: kind, + key: goal.key, + data: null, + url: window.location.href, + user: ident.getUser(), + creationDate: new Date().getTime(), + }; + + if (kind === 'click') { + event.selector = goal.selector; + } + + return enqueueEvent(event); + } + + function identify(user, hash, onDone) { + if (useLocalStorage) { + store.clearFlags(); + } + return utils.wrapPromiseCallback( + new Promise((resolve, reject) => { + if (!user || user.key === null || user.key === undefined) { + const err = new errors.LDInvalidUserError( + user ? messages.invalidUser() : messages.userNotSpecified() + ); + emitter.maybeReportError(err); + reject(err); + } else { + ident.setUser(user); + requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { + if (err) { + emitter.maybeReportError(new errors.LDFlagFetchError(messages.errorFetchingFlags(err))); + return reject(err); + } + if (settings) { + updateSettings(settings); + } + resolve(utils.transformVersionedValuesToValues(settings)); + if (subscribedToChangeEvents) { + connectStream(); + } + }); + } + }), + onDone + ); + } + + function flush(onDone) { + return utils.wrapPromiseCallback( + new Promise(resolve => (sendEvents ? resolve(events.flush()) : resolve()), onDone) + ); + } + + function variation(key, defaultValue) { + return variationDetailInternal(key, defaultValue, true).value; + } + + function variationDetail(key, defaultValue) { + return variationDetailInternal(key, defaultValue, true); + } + + function variationDetailInternal(key, defaultValue, sendEvent) { + let detail; + + if (flags && flags.hasOwnProperty(key) && flags[key] && !flags[key].deleted) { + const flag = flags[key]; + detail = getFlagDetail(flag); + if (flag.value === null || flag.value === undefined) { + detail.value = defaultValue; + } + } else { + detail = { value: defaultValue, variationIndex: null, reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }; + } + + if (sendEvent) { + sendFlagEvent(key, detail, defaultValue); + } + + return detail; + } + + function getFlagDetail(flag) { + return { + value: flag.value, + variationIndex: flag.variation === undefined ? null : flag.variation, + reason: flag.reason || null, + }; + // Note, the logic above ensures that variationIndex and reason will always be null rather than + // undefined if we don't have values for them. That's just to avoid subtle errors that depend on + // whether an object was JSON-encoded with null properties omitted or not. + } + + function doNotTrack() { + let flag; + if (navigator && navigator.doNotTrack !== undefined) { + flag = navigator.doNotTrack; // FF, Chrome + } else if (navigator && navigator.msDoNotTrack !== undefined) { + flag = navigator.msDoNotTrack; // IE 9/10 + } else { + flag = window.doNotTrack; // IE 11+, Safari + } + return flag === '1' || flag === 'yes'; + } + + function allFlags() { + const results = {}; + + if (!flags) { + return results; + } + + for (const key in flags) { + if (flags.hasOwnProperty(key)) { + results[key] = variationDetailInternal(key, null, !sendEventsOnlyForVariation).value; + } + } + + return results; + } + + function customEventExists(key) { + if (!goals || goals.length === 0) { + return false; + } + + for (let i = 0; i < goals.length; i++) { + if (goals[i].kind === 'custom' && goals[i].key === key) { + return true; + } + } + + return false; + } + + function track(key, data) { + if (typeof key !== 'string') { + emitter.maybeReportError(new errors.LDInvalidEventKeyError(messages.unknownCustomEventKey(key))); + return; + } + + // Validate key if we have goals + if (!!goals && !customEventExists(key)) { + console.warn(messages.unknownCustomEventKey(key)); + } + + enqueueEvent({ + kind: 'custom', + key: key, + data: data, + user: ident.getUser(), + url: window.location.href, + creationDate: new Date().getTime(), + }); + } + + function connectStream() { + if (!ident.getUser()) { + return; + } + stream.connect(ident.getUser(), { + ping: function() { + requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { + if (err) { + emitter.maybeReportError(new errors.LDFlagFetchError(messages.errorFetchingFlags(err))); + } + updateSettings(settings); + }); + }, + put: function(e) { + const data = JSON.parse(e.data); + updateSettings(data); + }, + patch: function(e) { + const data = JSON.parse(e.data); + // If both the flag and the patch have a version property, then the patch version must be + // greater than the flag version for us to accept the patch. If either one has no version + // then the patch always succeeds. + const oldFlag = flags[data.key]; + if (!oldFlag || !oldFlag.version || !data.version || oldFlag.version < data.version) { + const mods = {}; + const newFlag = utils.extend({}, data); + delete newFlag['key']; + flags[data.key] = newFlag; + const newDetail = getFlagDetail(newFlag); + if (oldFlag) { + mods[data.key] = { previous: oldFlag.value, current: newDetail }; + } else { + mods[data.key] = { current: newDetail }; + } + postProcessSettingsUpdate(mods); + } + }, + delete: function(e) { + const data = JSON.parse(e.data); + if (!flags[data.key] || flags[data.key].version < data.version) { + const mods = {}; + if (flags[data.key] && !flags[data.key].deleted) { + mods[data.key] = { previous: flags[data.key].value }; + } + flags[data.key] = { version: data.version, deleted: true }; + postProcessSettingsUpdate(mods); + } + }, + }); + } + + function updateSettings(newFlags) { + const changes = {}; + + if (!newFlags) { + return; + } + + for (const key in flags) { + if (flags.hasOwnProperty(key) && flags[key]) { + if (newFlags[key] && newFlags[key].value !== flags[key].value) { + changes[key] = { previous: flags[key].value, current: getFlagDetail(newFlags[key]) }; + } else if (!newFlags[key] || newFlags[key].deleted) { + changes[key] = { previous: flags[key].value }; + } + } + } + for (const key in newFlags) { + if (newFlags.hasOwnProperty(key) && newFlags[key] && (!flags[key] || flags[key].deleted)) { + changes[key] = { current: getFlagDetail(newFlags[key]) }; + } + } + + flags = newFlags; + postProcessSettingsUpdate(changes); + } + + function postProcessSettingsUpdate(changes) { + const keys = Object.keys(changes); + + if (useLocalStorage) { + store.saveFlags(flags); + } + + if (keys.length > 0) { + const changeEventParams = {}; + keys.forEach(key => { + const current = changes[key].current; + const value = current ? current.value : undefined; + const previous = changes[key].previous; + emitter.emit(changeEvent + ':' + key, value, previous); + changeEventParams[key] = current ? { current: value, previous: previous } : { previous: previous }; + }); + + emitter.emit(changeEvent, changeEventParams); + + if (!sendEventsOnlyForVariation) { + keys.forEach(key => { + sendFlagEvent(key, changes[key].current); + }); + } + } + } + + function on(event, handler, context) { + if (event.substr(0, changeEvent.length) === changeEvent) { + subscribedToChangeEvents = true; + if (!stream.isConnected()) { + connectStream(); + } + emitter.on.apply(emitter, [event, handler, context]); + } else { + emitter.on.apply(emitter, Array.prototype.slice.call(arguments)); + } + } + + function off(event) { + if (event === changeEvent) { + if ((subscribedToChangeEvents = true)) { + subscribedToChangeEvents = false; + stream.disconnect(); + } + } + emitter.off.apply(emitter, Array.prototype.slice.call(arguments)); + } + + function handleMessage(event) { + if (event.origin !== baseUrl) { + return; + } + if (event.data.type === 'SYN') { + window.editorClientBaseUrl = baseUrl; + const editorTag = document.createElement('script'); + editorTag.type = 'text/javascript'; + editorTag.async = true; + editorTag.src = baseUrl + event.data.editorClientUrl; + const s = document.getElementsByTagName('script')[0]; + s.parentNode.insertBefore(editorTag, s); + } + } + + if (!env) { + utils.onNextTick(() => { + emitter.maybeReportError(new errors.LDInvalidEnvironmentIdError(messages.environmentNotSpecified())); + }); + } + + if (!user) { + utils.onNextTick(() => { + emitter.maybeReportError(new errors.LDInvalidUserError(messages.userNotSpecified())); + }); + } else if (!user.key) { + utils.onNextTick(() => { + emitter.maybeReportError(new errors.LDInvalidUserError(messages.invalidUser())); + }); + } + + if (typeof options.bootstrap === 'object') { + utils.onNextTick(signalSuccessfulInit); + } else if ( + typeof options.bootstrap === 'string' && + options.bootstrap.toUpperCase() === 'LOCALSTORAGE' && + !!localStorage + ) { + useLocalStorage = true; + + flags = store.loadFlags(); + + if (flags === null) { + flags = {}; + requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { + if (err) { + const initErr = new errors.LDFlagFetchError(messages.errorFetchingFlags(err)); + signalFailedInit(initErr); + } else { + if (settings) { + flags = settings; + store.saveFlags(flags); + } else { + flags = {}; + } + signalSuccessfulInit(); + } + }); + } else { + // We're reading the flags from local storage. Signal that we're ready, + // then update localStorage for the next page load. We won't signal changes or update + // the in-memory flags unless you subscribe for changes + utils.onNextTick(signalSuccessfulInit); + + requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { + if (err) { + emitter.maybeReportError(new errors.LDFlagFetchError(messages.errorFetchingFlags(err))); + } + if (settings) { + store.saveFlags(settings); + } + }); + } + } else { + requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { + if (err) { + flags = {}; + const initErr = new errors.LDFlagFetchError(messages.errorFetchingFlags(err)); + signalFailedInit(initErr); + } else { + flags = settings || {}; + signalSuccessfulInit(); + } + }); + } + + function refreshGoalTracker() { + if (goalTracker) { + goalTracker.dispose(); + } + if (goals && goals.length) { + goalTracker = GoalTracker(goals, sendGoalEvent); + } + } + + function watchLocation(interval, callback) { + let previousUrl = location.href; + let currentUrl; + + function checkUrl() { + currentUrl = location.href; + + if (currentUrl !== previousUrl) { + previousUrl = currentUrl; + callback(); + } + } + + function poll(fn, interval) { + fn(); + setTimeout(() => { + poll(fn, interval); + }, interval); + } + + poll(checkUrl, interval); + + if (!!(window.history && history.pushState)) { + window.addEventListener('popstate', checkUrl); + } else { + window.addEventListener('hashchange', checkUrl); + } + } + + if (fetchGoals) { + requestor.fetchGoals((err, g) => { + if (err) { + emitter.maybeReportError( + new errors.LDUnexpectedResponseError('Error fetching goals: ' + err.message ? err.message : err) + ); + } + if (g && g.length > 0) { + goals = g; + goalTracker = GoalTracker(goals, sendGoalEvent); + watchLocation(locationWatcherInterval, refreshGoalTracker); + } + emitter.emit(goalsEvent); + }); + } + + function signalSuccessfulInit() { + emitter.emit(readyEvent); + emitter.emit(successEvent); // allows initPromise to distinguish between success and failure + } + + function signalFailedInit(err) { + emitter.maybeReportError(err); + emitter.emit(failedEvent, err); + emitter.emit(readyEvent); // for backward compatibility, this event happens even on failure + } + + function start() { + if (sendEvents) { + events.start(); + } + } + + if (document.readyState !== 'complete') { + window.addEventListener('load', start); + } else { + start(); + } + + window.addEventListener('beforeunload', () => { + if (sendEvents) { + events.stop(); + events.flush(true); + } + }); + + window.addEventListener('message', handleMessage); + + const readyPromise = new Promise(resolve => { + const onReady = emitter.on(readyEvent, () => { + emitter.off(readyEvent, onReady); + resolve(); + }); + }); + + const goalsPromise = new Promise(resolve => { + const onGoals = emitter.on(goalsEvent, () => { + emitter.off(goalsEvent, onGoals); + resolve(); + }); + }); + + const initPromise = new Promise((resolve, reject) => { + const onSuccess = emitter.on(successEvent, () => { + emitter.off(successEvent, onSuccess); + resolve(); + }); + const onFailure = emitter.on(failedEvent, err => { + emitter.off(failedEvent, onFailure); + reject(err); + }); + }); + + const client = { + waitForInitialization: () => initPromise, + waitUntilReady: () => readyPromise, + waitUntilGoalsReady: () => goalsPromise, + identify: identify, + variation: variation, + variationDetail: variationDetail, + track: track, + on: on, + off: off, + flush: flush, + allFlags: allFlags, + }; + + return client; +} + +export const version = VERSION; +export { messages }; + +function deprecatedInitialize(env, user, options = {}) { + console && console.warn && console.warn(messages.deprecated('default export', 'named LDClient export')); + return initialize(env, user, options); +} diff --git a/packages/ldclient-js-common/src/jest.setup.js b/packages/ldclient-js-common/src/jest.setup.js new file mode 100644 index 00000000..f64a0e0f --- /dev/null +++ b/packages/ldclient-js-common/src/jest.setup.js @@ -0,0 +1 @@ +// Test environment setup diff --git a/packages/ldclient-js/src/utils.js b/packages/ldclient-js-common/src/utils.js similarity index 100% rename from packages/ldclient-js/src/utils.js rename to packages/ldclient-js-common/src/utils.js diff --git a/packages/ldclient-js/src/index.js b/packages/ldclient-js/src/index.js index cd3f4d69..ada2fd2d 100644 --- a/packages/ldclient-js/src/index.js +++ b/packages/ldclient-js/src/index.js @@ -1,647 +1,10 @@ import * as common from 'ldclient-js-common'; -import EventProcessor from './EventProcessor'; -import EventEmitter from './EventEmitter'; -import GoalTracker from './GoalTracker'; -import Store from './Store'; -import Stream from './Stream'; -import Requestor from './Requestor'; -import Identity from './Identity'; -import * as utils from './utils'; -import * as errors from './errors'; - -const readyEvent = 'ready'; -const successEvent = 'initialized'; -const failedEvent = 'failed'; -const changeEvent = 'change'; -const goalsEvent = 'goalsReady'; -const locationWatcherInterval = 300; - export function initialize(env, user, options = {}) { - const baseUrl = options.baseUrl || 'https://app.launchdarkly.com'; - const eventsUrl = options.eventsUrl || 'https://events.launchdarkly.com'; - const streamUrl = options.streamUrl || 'https://clientstream.launchdarkly.com'; - const hash = options.hash; - const sendEvents = optionWithDefault('sendEvents', true); - const sendLDHeaders = optionWithDefault('sendLDHeaders', true); - const allowFrequentDuplicateEvents = !!options.allowFrequentDuplicateEvents; - const sendEventsOnlyForVariation = !!options.sendEventsOnlyForVariation; - const fetchGoals = typeof options.fetchGoals === 'undefined' ? true : options.fetchGoals; - const environment = env; - const emitter = EventEmitter(); - const stream = Stream(streamUrl, environment, hash, options); - const events = options.eventProcessor || EventProcessor(eventsUrl, environment, options, emitter); - const requestor = Requestor(baseUrl, environment, options.useReport, options.evaluationReasons, sendLDHeaders); - const seenRequests = {}; - let flags = typeof options.bootstrap === 'object' ? readFlagsFromBootstrap(options.bootstrap) : {}; - let goalTracker; - let useLocalStorage; - let goals; - let subscribedToChangeEvents; - let firstEvent = true; - - function optionWithDefault(name, defaultVal) { - return typeof options[name] === 'undefined' ? defaultVal : options[name]; - } - - function readFlagsFromBootstrap(data) { - // If the bootstrap data came from an older server-side SDK, we'll have just a map of keys to values. - // Newer SDKs that have an allFlagsState method will provide an extra "$flagsState" key that contains - // the rest of the metadata we want. We do it this way for backward compatibility with older JS SDKs. - const keys = Object.keys(data); - const metadataKey = '$flagsState'; - const validKey = '$valid'; - const metadata = data[metadataKey]; - if (!metadata && keys.length) { - console.warn(common.messages.bootstrapOldFormat()); - } - if (data[validKey] === false) { - console.warn(common.messages.bootstrapInvalid()); - } - const ret = {}; - keys.forEach(key => { - if (key !== metadataKey && key !== validKey) { - let flag = { value: data[key] }; - if (metadata && metadata[key]) { - flag = utils.extend(flag, metadata[key]); - } else { - flag.version = 0; - } - ret[key] = flag; - } - }); - return ret; - } - - function shouldEnqueueEvent() { - return sendEvents && !doNotTrack(); - } - - function enqueueEvent(event) { - if (!event.user) { - if (firstEvent) { - if (console && console.warn) { - console.warn( - 'Be sure to call `identify` in the LaunchDarkly client: http://docs.launchdarkly.com/docs/running-an-ab-test#include-the-client-side-snippet' - ); - } - firstEvent = false; - } - return; - } - firstEvent = false; - if (shouldEnqueueEvent()) { - events.enqueue(event); - } - } - - function sendIdentifyEvent(user) { - if (user) { - enqueueEvent({ - kind: 'identify', - key: user.key, - user: user, - creationDate: new Date().getTime(), - }); - } - } - - const ident = Identity(user, sendIdentifyEvent); - const store = Store(environment, hash, ident); - - function sendFlagEvent(key, detail, defaultValue) { - const user = ident.getUser(); - const now = new Date(); - const value = detail ? detail.value : null; - if (!allowFrequentDuplicateEvents) { - const cacheKey = JSON.stringify(value) + (user && user.key ? user.key : '') + key; // see below - const cached = seenRequests[cacheKey]; - // cache TTL is five minutes - if (cached && now - cached < 300000) { - return; - } - seenRequests[cacheKey] = now; - } - - const event = { - kind: 'feature', - key: key, - user: user, - value: value, - variation: detail ? detail.variationIndex : null, - default: defaultValue, - creationDate: now.getTime(), - reason: detail ? detail.reason : null, - }; - const flag = flags[key]; - if (flag) { - event.version = flag.flagVersion ? flag.flagVersion : flag.version; - event.trackEvents = flag.trackEvents; - event.debugEventsUntilDate = flag.debugEventsUntilDate; - } - - enqueueEvent(event); - } - - function sendGoalEvent(kind, goal) { - const event = { - kind: kind, - key: goal.key, - data: null, - url: window.location.href, - user: ident.getUser(), - creationDate: new Date().getTime(), - }; - - if (kind === 'click') { - event.selector = goal.selector; - } - - return enqueueEvent(event); - } - - function identify(user, hash, onDone) { - if (useLocalStorage) { - store.clearFlags(); - } - return utils.wrapPromiseCallback( - new Promise((resolve, reject) => { - if (!user || user.key === null || user.key === undefined) { - const err = new errors.LDInvalidUserError( - user ? common.messages.invalidUser() : common.messages.userNotSpecified() - ); - emitter.maybeReportError(err); - reject(err); - } else { - ident.setUser(user); - requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { - if (err) { - emitter.maybeReportError(new errors.LDFlagFetchError(common.messages.errorFetchingFlags(err))); - return reject(err); - } - if (settings) { - updateSettings(settings); - } - resolve(utils.transformVersionedValuesToValues(settings)); - if (subscribedToChangeEvents) { - connectStream(); - } - }); - } - }), - onDone - ); - } - - function flush(onDone) { - return utils.wrapPromiseCallback( - new Promise(resolve => (sendEvents ? resolve(events.flush()) : resolve()), onDone) - ); - } - - function variation(key, defaultValue) { - return variationDetailInternal(key, defaultValue, true).value; - } - - function variationDetail(key, defaultValue) { - return variationDetailInternal(key, defaultValue, true); - } - - function variationDetailInternal(key, defaultValue, sendEvent) { - let detail; - - if (flags && flags.hasOwnProperty(key) && flags[key] && !flags[key].deleted) { - const flag = flags[key]; - detail = getFlagDetail(flag); - if (flag.value === null || flag.value === undefined) { - detail.value = defaultValue; - } - } else { - detail = { value: defaultValue, variationIndex: null, reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }; - } - - if (sendEvent) { - sendFlagEvent(key, detail, defaultValue); - } - - return detail; - } - - function getFlagDetail(flag) { - return { - value: flag.value, - variationIndex: flag.variation === undefined ? null : flag.variation, - reason: flag.reason || null, - }; - // Note, the logic above ensures that variationIndex and reason will always be null rather than - // undefined if we don't have values for them. That's just to avoid subtle errors that depend on - // whether an object was JSON-encoded with null properties omitted or not. - } - - function doNotTrack() { - let flag; - if (navigator && navigator.doNotTrack !== undefined) { - flag = navigator.doNotTrack; // FF, Chrome - } else if (navigator && navigator.msDoNotTrack !== undefined) { - flag = navigator.msDoNotTrack; // IE 9/10 - } else { - flag = window.doNotTrack; // IE 11+, Safari - } - return flag === '1' || flag === 'yes'; - } - - function allFlags() { - const results = {}; - - if (!flags) { - return results; - } - - for (const key in flags) { - if (flags.hasOwnProperty(key)) { - results[key] = variationDetailInternal(key, null, !sendEventsOnlyForVariation).value; - } - } - - return results; - } - - function customEventExists(key) { - if (!goals || goals.length === 0) { - return false; - } - - for (let i = 0; i < goals.length; i++) { - if (goals[i].kind === 'custom' && goals[i].key === key) { - return true; - } - } - - return false; - } - - function track(key, data) { - if (typeof key !== 'string') { - emitter.maybeReportError(new errors.LDInvalidEventKeyError(common.messages.unknownCustomEventKey(key))); - return; - } - - // Validate key if we have goals - if (!!goals && !customEventExists(key)) { - console.warn(common.messages.unknownCustomEventKey(key)); - } - - enqueueEvent({ - kind: 'custom', - key: key, - data: data, - user: ident.getUser(), - url: window.location.href, - creationDate: new Date().getTime(), - }); - } - - function connectStream() { - if (!ident.getUser()) { - return; - } - stream.connect(ident.getUser(), { - ping: function() { - requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { - if (err) { - emitter.maybeReportError(new errors.LDFlagFetchError(common.messages.errorFetchingFlags(err))); - } - updateSettings(settings); - }); - }, - put: function(e) { - const data = JSON.parse(e.data); - updateSettings(data); - }, - patch: function(e) { - const data = JSON.parse(e.data); - // If both the flag and the patch have a version property, then the patch version must be - // greater than the flag version for us to accept the patch. If either one has no version - // then the patch always succeeds. - const oldFlag = flags[data.key]; - if (!oldFlag || !oldFlag.version || !data.version || oldFlag.version < data.version) { - const mods = {}; - const newFlag = utils.extend({}, data); - delete newFlag['key']; - flags[data.key] = newFlag; - const newDetail = getFlagDetail(newFlag); - if (oldFlag) { - mods[data.key] = { previous: oldFlag.value, current: newDetail }; - } else { - mods[data.key] = { current: newDetail }; - } - postProcessSettingsUpdate(mods); - } - }, - delete: function(e) { - const data = JSON.parse(e.data); - if (!flags[data.key] || flags[data.key].version < data.version) { - const mods = {}; - if (flags[data.key] && !flags[data.key].deleted) { - mods[data.key] = { previous: flags[data.key].value }; - } - flags[data.key] = { version: data.version, deleted: true }; - postProcessSettingsUpdate(mods); - } - }, - }); - } - - function updateSettings(newFlags) { - const changes = {}; - - if (!newFlags) { - return; - } - - for (const key in flags) { - if (flags.hasOwnProperty(key) && flags[key]) { - if (newFlags[key] && newFlags[key].value !== flags[key].value) { - changes[key] = { previous: flags[key].value, current: getFlagDetail(newFlags[key]) }; - } else if (!newFlags[key] || newFlags[key].deleted) { - changes[key] = { previous: flags[key].value }; - } - } - } - for (const key in newFlags) { - if (newFlags.hasOwnProperty(key) && newFlags[key] && (!flags[key] || flags[key].deleted)) { - changes[key] = { current: getFlagDetail(newFlags[key]) }; - } - } - - flags = newFlags; - postProcessSettingsUpdate(changes); - } - - function postProcessSettingsUpdate(changes) { - const keys = Object.keys(changes); - - if (useLocalStorage) { - store.saveFlags(flags); - } - - if (keys.length > 0) { - const changeEventParams = {}; - keys.forEach(key => { - const current = changes[key].current; - const value = current ? current.value : undefined; - const previous = changes[key].previous; - emitter.emit(changeEvent + ':' + key, value, previous); - changeEventParams[key] = current ? { current: value, previous: previous } : { previous: previous }; - }); - - emitter.emit(changeEvent, changeEventParams); - - if (!sendEventsOnlyForVariation) { - keys.forEach(key => { - sendFlagEvent(key, changes[key].current); - }); - } - } - } - - function on(event, handler, context) { - if (event.substr(0, changeEvent.length) === changeEvent) { - subscribedToChangeEvents = true; - if (!stream.isConnected()) { - connectStream(); - } - emitter.on.apply(emitter, [event, handler, context]); - } else { - emitter.on.apply(emitter, Array.prototype.slice.call(arguments)); - } - } - - function off(event) { - if (event === changeEvent) { - if ((subscribedToChangeEvents = true)) { - subscribedToChangeEvents = false; - stream.disconnect(); - } - } - emitter.off.apply(emitter, Array.prototype.slice.call(arguments)); - } - - function handleMessage(event) { - if (event.origin !== baseUrl) { - return; - } - if (event.data.type === 'SYN') { - window.editorClientBaseUrl = baseUrl; - const editorTag = document.createElement('script'); - editorTag.type = 'text/javascript'; - editorTag.async = true; - editorTag.src = baseUrl + event.data.editorClientUrl; - const s = document.getElementsByTagName('script')[0]; - s.parentNode.insertBefore(editorTag, s); - } - } - - if (!env) { - utils.onNextTick(() => { - emitter.maybeReportError(new errors.LDInvalidEnvironmentIdError(common.messages.environmentNotSpecified())); - }); - } - - if (!user) { - utils.onNextTick(() => { - emitter.maybeReportError(new errors.LDInvalidUserError(common.messages.userNotSpecified())); - }); - } else if (!user.key) { - utils.onNextTick(() => { - emitter.maybeReportError(new errors.LDInvalidUserError(common.messages.invalidUser())); - }); - } - - if (typeof options.bootstrap === 'object') { - utils.onNextTick(signalSuccessfulInit); - } else if ( - typeof options.bootstrap === 'string' && - options.bootstrap.toUpperCase() === 'LOCALSTORAGE' && - !!localStorage - ) { - useLocalStorage = true; - - flags = store.loadFlags(); - - if (flags === null) { - flags = {}; - requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { - if (err) { - const initErr = new errors.LDFlagFetchError(common.messages.errorFetchingFlags(err)); - signalFailedInit(initErr); - } else { - if (settings) { - flags = settings; - store.saveFlags(flags); - } else { - flags = {}; - } - signalSuccessfulInit(); - } - }); - } else { - // We're reading the flags from local storage. Signal that we're ready, - // then update localStorage for the next page load. We won't signal changes or update - // the in-memory flags unless you subscribe for changes - utils.onNextTick(signalSuccessfulInit); - - requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { - if (err) { - emitter.maybeReportError(new errors.LDFlagFetchError(common.messages.errorFetchingFlags(err))); - } - if (settings) { - store.saveFlags(settings); - } - }); - } - } else { - requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { - if (err) { - flags = {}; - const initErr = new errors.LDFlagFetchError(common.messages.errorFetchingFlags(err)); - signalFailedInit(initErr); - } else { - flags = settings || {}; - signalSuccessfulInit(); - } - }); - } - - function refreshGoalTracker() { - if (goalTracker) { - goalTracker.dispose(); - } - if (goals && goals.length) { - goalTracker = GoalTracker(goals, sendGoalEvent); - } - } - - function watchLocation(interval, callback) { - let previousUrl = location.href; - let currentUrl; - - function checkUrl() { - currentUrl = location.href; - - if (currentUrl !== previousUrl) { - previousUrl = currentUrl; - callback(); - } - } - - function poll(fn, interval) { - fn(); - setTimeout(() => { - poll(fn, interval); - }, interval); - } - - poll(checkUrl, interval); - - if (!!(window.history && history.pushState)) { - window.addEventListener('popstate', checkUrl); - } else { - window.addEventListener('hashchange', checkUrl); - } - } - - if (fetchGoals) { - requestor.fetchGoals((err, g) => { - if (err) { - emitter.maybeReportError( - new errors.LDUnexpectedResponseError('Error fetching goals: ' + err.message ? err.message : err) - ); - } - if (g && g.length > 0) { - goals = g; - goalTracker = GoalTracker(goals, sendGoalEvent); - watchLocation(locationWatcherInterval, refreshGoalTracker); - } - emitter.emit(goalsEvent); - }); - } - - function signalSuccessfulInit() { - emitter.emit(readyEvent); - emitter.emit(successEvent); // allows initPromise to distinguish between success and failure - } - - function signalFailedInit(err) { - emitter.maybeReportError(err); - emitter.emit(failedEvent, err); - emitter.emit(readyEvent); // for backward compatibility, this event happens even on failure - } - - function start() { - if (sendEvents) { - events.start(); - } - } - - if (document.readyState !== 'complete') { - window.addEventListener('load', start); - } else { - start(); - } - - window.addEventListener('beforeunload', () => { - if (sendEvents) { - events.stop(); - events.flush(true); - } - }); - - window.addEventListener('message', handleMessage); - - const readyPromise = new Promise(resolve => { - const onReady = emitter.on(readyEvent, () => { - emitter.off(readyEvent, onReady); - resolve(); - }); - }); - - const goalsPromise = new Promise(resolve => { - const onGoals = emitter.on(goalsEvent, () => { - emitter.off(goalsEvent, onGoals); - resolve(); - }); - }); - - const initPromise = new Promise((resolve, reject) => { - const onSuccess = emitter.on(successEvent, () => { - emitter.off(successEvent, onSuccess); - resolve(); - }); - const onFailure = emitter.on(failedEvent, err => { - emitter.off(failedEvent, onFailure); - reject(err); - }); - }); - - const client = { - waitForInitialization: () => initPromise, - waitUntilReady: () => readyPromise, - waitUntilGoalsReady: () => goalsPromise, - identify: identify, - variation: variation, - variationDetail: variationDetail, - track: track, - on: on, - off: off, - flush: flush, - allFlags: allFlags, - }; - - return client; + return common.initialize(env, user, options); } -export const version = VERSION; +export const version = common.version; function deprecatedInitialize(env, user, options = {}) { console && console.warn && console.warn(common.messages.deprecated('default export', 'named LDClient export')); From e0405781f411fdd18438b37dee4891104f8ea8ba Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 9 Oct 2018 17:02:29 -0700 Subject: [PATCH 2/5] rm unused code --- packages/ldclient-js-common/src/index.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/ldclient-js-common/src/index.js b/packages/ldclient-js-common/src/index.js index 719cb20c..85f94327 100644 --- a/packages/ldclient-js-common/src/index.js +++ b/packages/ldclient-js-common/src/index.js @@ -642,8 +642,3 @@ export function initialize(env, user, options = {}) { export const version = VERSION; export { messages }; - -function deprecatedInitialize(env, user, options = {}) { - console && console.warn && console.warn(messages.deprecated('default export', 'named LDClient export')); - return initialize(env, user, options); -} From 4b1af0b84f561d5515247836fb6ede8d9e652a69 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 9 Oct 2018 17:03:29 -0700 Subject: [PATCH 3/5] linter --- packages/ldclient-js-common/src/UserFilter.js | 8 ++------ packages/ldclient-js-common/src/index.js | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/ldclient-js-common/src/UserFilter.js b/packages/ldclient-js-common/src/UserFilter.js index d3ca4513..2a79f2b4 100644 --- a/packages/ldclient-js-common/src/UserFilter.js +++ b/packages/ldclient-js-common/src/UserFilter.js @@ -29,14 +29,10 @@ export default function UserFilter(config) { }; if (config.all_attributes_private !== undefined) { - console && - console.warn && - console.warn(messages.deprecated('all_attributes_private', 'allAttributesPrivate')); + console && console.warn && console.warn(messages.deprecated('all_attributes_private', 'allAttributesPrivate')); } if (config.private_attribute_names !== undefined) { - console && - console.warn && - console.warn(messages.deprecated('private_attribute_names', 'privateAttributeNames')); + console && console.warn && console.warn(messages.deprecated('private_attribute_names', 'privateAttributeNames')); } filter.filterUser = function(user) { diff --git a/packages/ldclient-js-common/src/index.js b/packages/ldclient-js-common/src/index.js index 85f94327..b832a2e3 100644 --- a/packages/ldclient-js-common/src/index.js +++ b/packages/ldclient-js-common/src/index.js @@ -166,9 +166,7 @@ export function initialize(env, user, options = {}) { return utils.wrapPromiseCallback( new Promise((resolve, reject) => { if (!user || user.key === null || user.key === undefined) { - const err = new errors.LDInvalidUserError( - user ? messages.invalidUser() : messages.userNotSpecified() - ); + const err = new errors.LDInvalidUserError(user ? messages.invalidUser() : messages.userNotSpecified()); emitter.maybeReportError(err); reject(err); } else { From f95c5d2ff51cc556fb00f52711810d44bc16423c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 9 Oct 2018 17:08:14 -0700 Subject: [PATCH 4/5] add desultory test --- .../src/__tests__/LDClient-test.js | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 packages/ldclient-js/src/__tests__/LDClient-test.js diff --git a/packages/ldclient-js/src/__tests__/LDClient-test.js b/packages/ldclient-js/src/__tests__/LDClient-test.js new file mode 100644 index 00000000..5d02443e --- /dev/null +++ b/packages/ldclient-js/src/__tests__/LDClient-test.js @@ -0,0 +1,53 @@ +import sinon from 'sinon'; + +import * as LDClient from '../index'; + +describe('LDClient', () => { + const envName = 'UNKNOWN_ENVIRONMENT_ID'; + const user = { key: 'user' }; + let warnSpy; + let errorSpy; + let xhr; + let requests = []; + + beforeEach(() => { + xhr = sinon.useFakeXMLHttpRequest(); + xhr.onCreate = function(req) { + requests.push(req); + }; + + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + requests = []; + xhr.restore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + function getLastRequest() { + return requests[requests.length - 1]; + } + + it('should exist', () => { + expect(LDClient).toBeDefined(); + }); + + describe('initialization', () => { + it('should trigger the ready event', done => { + const handleReady = jest.fn(); + const client = LDClient.initialize(envName, user, { + bootstrap: {}, + }); + + client.on('ready', handleReady); + + setTimeout(() => { + expect(handleReady).toHaveBeenCalled(); + done(); + }, 0); + }); + }); +}); From 2cab87da2a71a2a22373284e79a4fed3fa9d1cda Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 9 Oct 2018 17:13:28 -0700 Subject: [PATCH 5/5] rm unused code --- packages/ldclient-js/src/__tests__/LDClient-test.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/ldclient-js/src/__tests__/LDClient-test.js b/packages/ldclient-js/src/__tests__/LDClient-test.js index 5d02443e..f6127959 100644 --- a/packages/ldclient-js/src/__tests__/LDClient-test.js +++ b/packages/ldclient-js/src/__tests__/LDClient-test.js @@ -27,10 +27,6 @@ describe('LDClient', () => { errorSpy.mockRestore(); }); - function getLastRequest() { - return requests[requests.length - 1]; - } - it('should exist', () => { expect(LDClient).toBeDefined(); });