diff --git a/CHANGELOG.md b/CHANGELOG.md index c30cc048..651bc8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the LaunchDarkly client-side JavaScript SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [2.8.0] - 2018-12-03 +### Added: +- The use of a streaming connection to LaunchDarkly for receiving live updates can now be controlled with the new `client.setStreaming()` method, or the equivalent boolean `streaming` property in the client configuration. If you set this to `false`, the client will not open a streaming connection even if you subscribe to change events (you might want to do this if, for instance, you just want to be notified when the client gets new flag values due to having switched users). If you set it to `true`, the client will open a streaming connection regardless of whether you subscribe to change events or not (the flag values will simply be updated in the background). If you don't set it either way then the default behavior still applies, i.e. the client opens a streaming connection if and only if you subscribe to change events. + +### Fixed: +- If the client opened a streaming connection because you called `on('change', ...)` one or more times, it will not close the connection until you call `off()` for _all_ of your event listeners. Previously, it was closing the connection whenever `off('change')` was called, even if you still had a listener for `'change:specific-flag-key'`. +- The client's logic for signaling a `change` event was using a regular Javascript `===` comparison, so it could incorrectly decide that a flag had changed if its value was a JSON object or an array. This has been fixed to use deep equality checking for object and array values. + ## [2.7.5] - 2018-11-21 ### Fixed: - When using the [`event-source-polyfill`](https://github.com/Yaffle/EventSource) package to allow streaming mode in browsers with no native EventSource support, the polyfill was using a default read timeout of 45 seconds, so if no updates arrived within 45 seconds it would log an error and reconnect the stream. The SDK now sets its own timeout (5 minutes) which will be used if this particular polyfill is active. LaunchDarkly normally sends a heartbeat every 3 minutes, so you should not see a timeout happen unless the connection has been lost. diff --git a/README.md b/README.md index e1216d72..8c223466 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The LaunchDarkly client-side JavaScript SDK supports the following browsers: * Chrome (any recent) * Firefox (any recent) -* Safari (any recent)\* +* Safari (any recent) * Internet Explorer (IE10+)\* * Edge (any recent)\* * Opera (any recent)\* @@ -67,7 +67,7 @@ Then import it before the module that initializes the LaunchDarkly client: ### Promise polyfill -The newer versions of the use `Promise`. If you need to support older browsers, you will +Newer versions of the SDK use `Promise`. If you need to support older browsers, you will need to install a polyfill for it, such as [es6-promise](https://github.com/stefanpenner/es6-promise). #### CDN diff --git a/package-lock.json b/package-lock.json index dad90f21..74ae3fbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ldclient-js", - "version": "2.7.5", + "version": "2.8.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -322,6 +322,14 @@ "fast-deep-equal": "^1.0.0", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.3.0" + }, + "dependencies": { + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "http://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=", + "dev": true + } } }, "ajv-keywords": { @@ -2962,10 +2970,9 @@ "dev": true }, "fast-deep-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", - "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=", - "dev": true + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" }, "fast-diff": { "version": "1.1.2", diff --git a/package.json b/package.json index 80ac2cb3..b6e3f0d9 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ldclient-js", - "version": "2.7.5", + "version": "2.8.0", "description": "LaunchDarkly SDK for JavaScript", "author": "LaunchDarkly ", "license": "Apache-2.0", @@ -79,7 +79,8 @@ }, "dependencies": { "base64-js": "1.3.0", - "escape-string-regexp": "1.0.5" + "escape-string-regexp": "1.0.5", + "fast-deep-equal": "2.0.1" }, "repository": { "type": "git", diff --git a/src/EventEmitter.js b/src/EventEmitter.js index 63484972..49857293 100644 --- a/src/EventEmitter.js +++ b/src/EventEmitter.js @@ -32,6 +32,14 @@ export default function EventEmitter() { } }; + emitter.getEvents = function() { + return Object.keys(events); + }; + + emitter.getEventListenerCount = function(event) { + return events[event] ? events[event].length : 0; + }; + emitter.maybeReportError = function(error) { if (!error) { return; diff --git a/src/__tests__/LDClient-streaming-test.js b/src/__tests__/LDClient-streaming-test.js index 5904d190..8b00085b 100644 --- a/src/__tests__/LDClient-streaming-test.js +++ b/src/__tests__/LDClient-streaming-test.js @@ -44,37 +44,181 @@ describe('LDClient', () => { describe('streaming/event listening', () => { const streamUrl = 'https://clientstream.launchdarkly.com'; + const fullStreamUrlWithUser = streamUrl + '/eval/' + envName + '/' + encodedUser; function streamEvents() { - return sources[`${streamUrl}/eval/${envName}/${encodedUser}`].__emitter._events; + return sources[fullStreamUrlWithUser].__emitter._events; + } + + function expectStreamUrlIsOpen(url) { + expect(Object.keys(sources)).toEqual([url]); + } + + function expectNoStreamIsOpen() { + expect(sources).toMatchObject({}); } it('does not connect to the stream by default', done => { const client = LDClient.initialize(envName, user, { bootstrap: {} }); client.on('ready', () => { - expect(sources).toMatchObject({}); + expectNoStreamIsOpen(); done(); }); }); - it('connects to the stream when listening to global change events', done => { - const client = LDClient.initialize(envName, user, { bootstrap: {} }); + it('connects to the stream if options.streaming is true', done => { + const client = LDClient.initialize(envName, user, { bootstrap: {}, streaming: true }); client.on('ready', () => { - client.on('change', () => {}); - expect(Object.keys(sources)).toEqual([streamUrl + '/eval/' + envName + '/' + encodedUser]); + expectStreamUrlIsOpen(fullStreamUrlWithUser); done(); }); }); - it('connects to the stream when listening to change event for one flag', done => { - const client = LDClient.initialize(envName, user, { bootstrap: {} }); + describe('setStreaming()', () => { + it('can connect to the stream', done => { + const client = LDClient.initialize(envName, user, { bootstrap: {} }); - client.on('ready', () => { - client.on('change:flagkey', () => {}); - expect(Object.keys(sources)).toEqual([streamUrl + '/eval/' + envName + '/' + encodedUser]); - done(); + client.on('ready', () => { + client.setStreaming(true); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + done(); + }); + }); + + it('can disconnect from the stream', done => { + const client = LDClient.initialize(envName, user, { bootstrap: {} }); + + client.on('ready', () => { + client.setStreaming(true); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.setStreaming(false); + expectNoStreamIsOpen(); + done(); + }); + }); + }); + + describe('on("change")', () => { + it('connects to the stream if not otherwise overridden', done => { + const client = LDClient.initialize(envName, user, { bootstrap: {} }); + + client.on('ready', () => { + client.on('change', () => {}); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + done(); + }); + }); + + it('also connects if listening for a specific flag', done => { + const client = LDClient.initialize(envName, user, { bootstrap: {} }); + + client.on('ready', () => { + client.on('change:flagkey', () => {}); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + done(); + }); + }); + + it('does not connect if some other kind of event was specified', done => { + const client = LDClient.initialize(envName, user, { bootstrap: {} }); + + client.on('ready', () => { + client.on('error', () => {}); + expectNoStreamIsOpen(); + done(); + }); + }); + + it('does not connect if options.streaming is explicitly set to false', done => { + const client = LDClient.initialize(envName, user, { bootstrap: {}, streaming: false }); + + client.on('ready', () => { + client.on('change', () => {}); + expectNoStreamIsOpen(); + done(); + }); + }); + + it('does not connect if setStreaming(false) was called', done => { + const client = LDClient.initialize(envName, user, { bootstrap: {} }); + + client.on('ready', () => { + client.setStreaming(false); + client.on('change', () => {}); + expectNoStreamIsOpen(); + done(); + }); + }); + }); + + describe('off("change")', () => { + it('disconnects from the stream if all event listeners are removed', done => { + const client = LDClient.initialize(envName, user, { bootstrap: {} }); + const listener1 = () => {}; + const listener2 = () => {}; + + client.on('ready', () => { + client.on('change', listener1); + client.on('change:flagkey', listener2); + client.on('error', () => {}); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + + client.off('change', listener1); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + + client.off('change:flagkey', listener2); + expectNoStreamIsOpen(); + + done(); + }); + }); + + it('does not disconnect if setStreaming(true) was called, but still removes event listener', done => { + const changes1 = []; + const changes2 = []; + + const client = LDClient.initialize(envName, user, { bootstrap: {} }); + const listener1 = allValues => changes1.push(allValues); + const listener2 = newValue => changes2.push(newValue); + + client.on('ready', () => { + client.setStreaming(true); + + client.on('change', listener1); + client.on('change:flag', listener2); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + + streamEvents().put({ + data: '{"flag":{"value":"a","version":1}}', + }); + + expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); + expect(changes2).toEqual(['a']); + + client.off('change', listener1); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + + streamEvents().put({ + data: '{"flag":{"value":"b","version":1}}', + }); + + expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); + expect(changes2).toEqual(['a', 'b']); + + client.off('change:flag', listener2); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + + streamEvents().put({ + data: '{"flag":{"value":"c","version":1}}', + }); + + expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); + expect(changes2).toEqual(['a', 'b']); + + done(); + }); }); }); @@ -83,7 +227,7 @@ describe('LDClient', () => { client.on('ready', () => { client.on('change:flagkey', () => {}); - expect(Object.keys(sources)).toEqual([streamUrl + '/eval/' + envName + '/' + encodedUser + '?h=' + hash]); + expectStreamUrlIsOpen(fullStreamUrlWithUser + '?h=' + hash); done(); }); }); @@ -93,9 +237,7 @@ describe('LDClient', () => { client.on('ready', () => { client.on('change', () => {}); - expect(Object.keys(sources)).toEqual([ - streamUrl + '/eval/' + envName + '/' + encodedUser + '?withReasons=true', - ]); + expectStreamUrlIsOpen(fullStreamUrlWithUser + '?withReasons=true'); done(); }); }); @@ -105,9 +247,7 @@ describe('LDClient', () => { client.on('ready', () => { client.on('change', () => {}); - expect(Object.keys(sources)).toEqual([ - streamUrl + '/eval/' + envName + '/' + encodedUser + '?h=' + hash + '&withReasons=true', - ]); + expectStreamUrlIsOpen(fullStreamUrlWithUser + '?h=' + hash + '&withReasons=true'); done(); }); }); @@ -181,6 +321,31 @@ describe('LDClient', () => { }); }); + it('does not fire change event if new and old values are equivalent JSON objects', done => { + const client = LDClient.initialize(envName, user, { + bootstrap: { + 'will-change': 3, + 'wont-change': { a: 1, b: 2 }, + }, + }); + + client.on('ready', () => { + client.on('change', changes => { + expect(changes).toEqual({ + 'will-change': { current: 4, previous: 3 }, + }); + + done(); + }); + + const putData = { + 'will-change': { value: 4, version: 2 }, + 'wont-change': { value: { b: 2, a: 1 }, version: 2 }, + }; + streamEvents().put({ data: JSON.stringify(putData) }); + }); + }); + it('fires individual change event when flags are updated from put event', done => { const client = LDClient.initialize(envName, user, { bootstrap: { 'enable-foo': false } }); diff --git a/src/__tests__/LDClient-test.js b/src/__tests__/LDClient-test.js index 1688a160..723407fd 100644 --- a/src/__tests__/LDClient-test.js +++ b/src/__tests__/LDClient-test.js @@ -217,6 +217,25 @@ describe('LDClient', () => { .catch(() => {}); }); + it('should load flags from local storage and then request newer ones', done => { + const json = '{"flag": "a"}'; + + window.localStorage.setItem(lsKey, json); + + const client = LDClient.initialize(envName, user, { bootstrap: 'localstorage', streaming: false }); + + client.waitForInitialization().then(() => { + expect(client.variation('flag')).toEqual('a'); + + client.on('change:flag', newValue => { + expect(newValue).toEqual('b'); + done(); + }); + + requests[0].respond(200, { 'Content-Type': 'application/json' }, '{"flag": {"value": "b", "version": 2}}'); + }); + }); + it('should start with empty flags if we tried to use cached settings and there are none', done => { window.localStorage.removeItem(lsKey); diff --git a/src/index.js b/src/index.js index 09a32950..c7b0516c 100644 --- a/src/index.js +++ b/src/index.js @@ -36,6 +36,8 @@ export function initialize(env, user, options = {}) { let goalTracker; let useLocalStorage; let goals; + let streamActive; + let streamForcedState; let subscribedToChangeEvents; let firstEvent = true; @@ -180,7 +182,7 @@ export function initialize(env, user, options = {}) { updateSettings(settings); } resolve(utils.transformVersionedValuesToValues(settings)); - if (subscribedToChangeEvents) { + if (streamActive) { connectStream(); } }); @@ -299,6 +301,7 @@ export function initialize(env, user, options = {}) { } function connectStream() { + streamActive = true; if (!ident.getUser()) { return; } @@ -349,6 +352,13 @@ export function initialize(env, user, options = {}) { }); } + function disconnectStream() { + if (streamActive) { + stream.disconnect(); + streamActive = false; + } + } + function updateSettings(newFlags) { const changes = {}; @@ -358,7 +368,7 @@ export function initialize(env, user, options = {}) { for (const key in flags) { if (flags.hasOwnProperty(key) && flags[key]) { - if (newFlags[key] && newFlags[key].value !== flags[key].value) { + if (newFlags[key] && !utils.deepEquals(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 }; @@ -403,25 +413,50 @@ export function initialize(env, user, options = {}) { } function on(event, handler, context) { - if (event.substr(0, changeEvent.length) === changeEvent) { + if (isChangeEventKey(event)) { subscribedToChangeEvents = true; - if (!stream.isConnected()) { + if (!streamActive && streamForcedState === undefined) { connectStream(); } - emitter.on.apply(emitter, [event, handler, context]); + emitter.on(event, handler, context); } else { - emitter.on.apply(emitter, Array.prototype.slice.call(arguments)); + emitter.on(...arguments); } } function off(event) { - if (event === changeEvent) { - if ((subscribedToChangeEvents = true)) { + emitter.off(...arguments); + if (isChangeEventKey(event)) { + let haveListeners = false; + emitter.getEvents().forEach(key => { + if (isChangeEventKey(key) && emitter.getEventListenerCount(key) > 0) { + haveListeners = true; + } + }); + if (!haveListeners) { subscribedToChangeEvents = false; - stream.disconnect(); + if (streamActive && streamForcedState === undefined) { + disconnectStream(); + } + } + } + } + + function setStreaming(state) { + const newState = state === null ? undefined : state; + if (newState !== streamForcedState) { + streamForcedState = newState; + const shouldBeStreaming = streamForcedState || (subscribedToChangeEvents && streamForcedState === undefined); + if (shouldBeStreaming && !streamActive) { + connectStream(); + } else if (!shouldBeStreaming && streamActive) { + disconnectStream(); } } - emitter.off.apply(emitter, Array.prototype.slice.call(arguments)); + } + + function isChangeEventKey(event) { + return event === changeEvent || event.substr(0, changeEvent.length + 1) === changeEvent + ':'; } function handleMessage(event) { @@ -474,8 +509,7 @@ export function initialize(env, user, options = {}) { signalFailedInit(initErr); } else { if (settings) { - flags = settings; - store.saveFlags(flags); + updateSettings(settings); // this includes saving to local storage and sending change events } else { flags = {}; } @@ -483,9 +517,9 @@ export function initialize(env, user, options = {}) { } }); } 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 + // We're reading the flags from local storage. Signal that we're ready immediately, but also + // start a request in the background to get newer flags. When we receive those, we will update + // localStorage, and will also send change events if the values have changed. utils.onNextTick(signalSuccessfulInit); requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { @@ -493,7 +527,7 @@ export function initialize(env, user, options = {}) { emitter.maybeReportError(new errors.LDFlagFetchError(messages.errorFetchingFlags(err))); } if (settings) { - store.saveFlags(settings); + updateSettings(settings); // this includes saving to local storage and sending change events } }); } @@ -565,6 +599,9 @@ export function initialize(env, user, options = {}) { } function signalSuccessfulInit() { + if (options.streaming !== undefined) { + setStreaming(options.streaming); + } emitter.emit(readyEvent); emitter.emit(successEvent); // allows initPromise to distinguish between success and failure } @@ -631,6 +668,7 @@ export function initialize(env, user, options = {}) { track: track, on: on, off: off, + setStreaming: setStreaming, flush: flush, allFlags: allFlags, }; diff --git a/src/utils.js b/src/utils.js index c716232a..0757575f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,5 @@ import * as base64 from 'base64-js'; +import fastDeepEqual from 'fast-deep-equal'; // See http://ecmanaut.blogspot.com/2006/07/encoding-decoding-utf8-in-javascript.html export function btoa(s) { @@ -28,6 +29,10 @@ export function clone(obj) { return JSON.parse(JSON.stringify(obj)); } +export function deepEquals(a, b) { + return fastDeepEqual(a, b); +} + // Events emitted in LDClient's initialize method will happen before the consumer // can register a listener, so defer them to next tick. export function onNextTick(cb) { diff --git a/typings.d.ts b/typings.d.ts index efebf536..674d9332 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -73,9 +73,10 @@ declare module 'ldclient-js' { * flag value, and the previous value. This is always accompanied by a general * "change" event as described above; you can listen for either or both. * - * The "change" and "change:FLAG-KEY" events have special behavior: the client - * will open a streaming connection to receive live changes if and only if you - * are listening for one of these events. + * The "change" and "change:FLAG-KEY" events have special behavior: by default, the + * client will open a streaming connection to receive live changes if and only if + * you are listening for one of these events. This behavior can be overridden by + * setting LDOptions.streaming or calling LDClient.setStreaming(). */ type LDEventSignature = ( key: string, @@ -128,6 +129,17 @@ declare module 'ldclient-js' { */ streamUrl?: string; + /** + * 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 LDClient.on()). + * + * This is equivalent to calling client.setStreaming() with the same value. + */ + streaming?: boolean; + /** * Whether or not to use the REPORT verb to fetch flag settings. * @@ -424,6 +436,17 @@ declare module 'ldclient-js' { */ variationDetail: (key: string, defaultValue?: LDFlagValue) => LDEvaluationDetail; + /** + * 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 LDClient.on()). + * + * This can also be set as the "streaming" property of the client options. + */ + setStreaming: (value?: boolean) => void; + /** * Registers an event listener. See LDEventSignature for the available event types * and the data that can be associated with them.