launchdarkly-js-sdk-common 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.yaml CHANGED
@@ -11,7 +11,6 @@ plugins:
11
11
  - babel
12
12
  - prettier
13
13
  globals:
14
- VERSION: true
15
14
  describe: true
16
15
  it: true
17
16
  expect: true
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to the `launchdarkly-js-sdk-common` package will be documented in this file. Changes that affect the dependent SDKs such as `launchdarkly-js-client-sdk` should also be logged in those projects, in the next release that uses the updated version of this package. This project adheres to [Semantic Versioning](http://semver.org).
4
4
 
5
+ ## [4.0.3] - 2022-02-16
6
+ ### Fixed:
7
+ - If the SDK receives invalid JSON data from a streaming connection (possibly as a result of the connection being cut off), it now uses its regular error-handling logic: the error is emitted as an `error` event or, if there are no `error` event listeners, it is logged. Previously, it would be thrown as an unhandled exception.
8
+
9
+ ## [4.0.2] - 2022-01-25
10
+ ### Removed:
11
+ - Removed the `version` export which was originally a constant inserted by the Rollup build, but was no longer usable since Rollup is no longer being used. The SDKs never used this export, since they have `version` properties of their own; the version string of `launchdarkly-js-sdk-common` was never meant to be exposed to applications.
12
+
5
13
  ## [4.0.1] - 2022-01-21
6
14
  ### Changed:
7
15
  - This package is now published as a regular Node module. Previously, it was published as minified bundles created by Rollup. There was no need for this since Rollup is only needed for web code, and the `js-client-sdk` build already runs Rollup to embed the `js-sdk-common` code. Using Rollup caused the platform-dependent behavior of `uuid` to fail because the code for only one platform (browser or Node) was embedded.
@@ -15,6 +23,10 @@ All notable changes to the `launchdarkly-js-sdk-common` package will be document
15
23
  - Removed the type `NonNullableLDEvaluationReason`, which was a side effect of the `LDEvaluationDetail.reason` being incorrectly defined before.
16
24
  - Removed all types, properties, and functions that were deprecated as of the last 3.x release.
17
25
 
26
+ ## [3.5.1] - 2022-02-17
27
+ ### Fixed:
28
+ - If the SDK receives invalid JSON data from a streaming connection (possibly as a result of the connection being cut off), it now uses its regular error-handling logic: the error is emitted as an `error` event or, if there are no `error` event listeners, it is logged. Previously, it would be thrown as an unhandled exception.
29
+
18
30
  ## [3.5.0] - 2022-01-14
19
31
  ### Added:
20
32
  - New configurable logger factory `commonBasicLogger` and `BasicLoggerOptions`. The `commonBasicLogger` method is not intended to be exported directly in the SDKs, but wrapped to provide platform-specific behavior.
package/docs/typedoc.js CHANGED
@@ -4,7 +4,7 @@ module.exports = {
4
4
  '**/node_modules/**',
5
5
  'test-types.ts'
6
6
  ],
7
- name: "LaunchDarkly Javascript SDK Core Components (4.0.1)",
7
+ name: "LaunchDarkly Javascript SDK Core Components (4.0.2)",
8
8
  readme: 'none', // don't add a home page with a copy of README.md
9
9
  entryPoints: "/tmp/project-releaser/project/typings.d.ts",
10
10
  entryPointStrategy: "expand"
package/jest.config.js CHANGED
@@ -9,7 +9,4 @@ module.exports = {
9
9
  transform: {
10
10
  '^.+\\.js$': 'babel-jest',
11
11
  },
12
- globals: {
13
- VERSION: version,
14
- },
15
12
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchdarkly-js-sdk-common",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "LaunchDarkly SDK for JavaScript - common code",
5
5
  "author": "LaunchDarkly <team@launchdarkly.com>",
6
6
  "license": "Apache-2.0",
@@ -40,8 +40,6 @@
40
40
  "launchdarkly-js-test-helpers": "1.1.0",
41
41
  "prettier": "1.11.1",
42
42
  "readline-sync": "^1.4.9",
43
- "semver": "^5.5.0",
44
- "semver-compare": "^1.0.0",
45
43
  "typescript": "~4.4.4"
46
44
  },
47
45
  "dependencies": {
@@ -15,7 +15,7 @@ function EventProcessor(
15
15
  ) {
16
16
  const processor = {};
17
17
  const eventSender = sender || EventSender(platform, environmentId, options);
18
- const mainEventsUrl = options.eventsUrl + '/events/bulk/' + environmentId;
18
+ const mainEventsUrl = utils.appendUrlPath(options.eventsUrl, '/events/bulk/' + environmentId);
19
19
  const summarizer = EventSummarizer();
20
20
  const userFilter = UserFilter(options);
21
21
  const inlineUsers = options.inlineUsersInEvents;
@@ -1,12 +1,13 @@
1
1
  const errors = require('./errors');
2
2
  const utils = require('./utils');
3
3
  const { v1: uuidv1 } = require('uuid');
4
+ const { getLDHeaders, transformHeaders } = require('./headers');
4
5
 
5
6
  const MAX_URL_LENGTH = 2000;
6
7
 
7
8
  function EventSender(platform, environmentId, options) {
8
9
  const imageUrlPath = '/a/' + environmentId + '.gif';
9
- const baseHeaders = utils.extend({ 'Content-Type': 'application/json' }, utils.getLDHeaders(platform, options));
10
+ const baseHeaders = utils.extend({ 'Content-Type': 'application/json' }, getLDHeaders(platform, options));
10
11
  const httpFallbackPing = platform.httpFallbackPing; // this will be set for us if we're in the browser SDK
11
12
  const sender = {};
12
13
 
@@ -34,7 +35,7 @@ function EventSender(platform, environmentId, options) {
34
35
  'X-LaunchDarkly-Payload-ID': payloadId,
35
36
  });
36
37
  return platform
37
- .httpRequest('POST', url, utils.transformHeaders(headers, options), jsonBody)
38
+ .httpRequest('POST', url, transformHeaders(headers, options), jsonBody)
38
39
  .promise.then(result => {
39
40
  if (!result) {
40
41
  // This was a response from a fire-and-forget request, so we won't have a status.
package/src/Requestor.js CHANGED
@@ -2,6 +2,7 @@ const utils = require('./utils');
2
2
  const errors = require('./errors');
3
3
  const messages = require('./messages');
4
4
  const promiseCoalescer = require('./promiseCoalescer');
5
+ const { transformHeaders, getLDHeaders } = require('./headers');
5
6
 
6
7
  const jsonContentType = 'application/json';
7
8
 
@@ -31,7 +32,7 @@ function Requestor(platform, options, environment) {
31
32
  }
32
33
 
33
34
  const method = body ? 'REPORT' : 'GET';
34
- const headers = utils.getLDHeaders(platform, options);
35
+ const headers = getLDHeaders(platform, options);
35
36
  if (body) {
36
37
  headers['Content-Type'] = jsonContentType;
37
38
  }
@@ -45,7 +46,7 @@ function Requestor(platform, options, environment) {
45
46
  activeRequests[endpoint] = coalescer;
46
47
  }
47
48
 
48
- const req = platform.httpRequest(method, endpoint, utils.transformHeaders(headers, options), body);
49
+ const req = platform.httpRequest(method, endpoint, transformHeaders(headers, options), body);
49
50
  const p = req.promise.then(
50
51
  result => {
51
52
  if (result.status === 200) {
@@ -75,7 +76,7 @@ function Requestor(platform, options, environment) {
75
76
  // Performs a GET request to an arbitrary path under baseUrl. Returns a Promise which will resolve
76
77
  // with the parsed JSON response, or will be rejected if the request failed.
77
78
  requestor.fetchJSON = function(path) {
78
- return fetchJSON(baseUrl + path, null);
79
+ return fetchJSON(utils.appendUrlPath(baseUrl, path), null);
79
80
  };
80
81
 
81
82
  // Requests the current state of all flags for the given user from LaunchDarkly. Returns a Promise
package/src/Stream.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const messages = require('./messages');
2
- const { base64URLEncode, getLDHeaders, transformHeaders, objectHasOwnProperty } = require('./utils');
2
+ const { appendUrlPath, base64URLEncode, objectHasOwnProperty } = require('./utils');
3
+ const { getLDHeaders, transformHeaders } = require('./headers');
3
4
 
4
5
  // The underlying event source implementation is abstracted via the platform object, which should
5
6
  // have these three properties:
@@ -20,7 +21,7 @@ function Stream(platform, config, environment, diagnosticsAccumulator) {
20
21
  const baseUrl = config.streamUrl;
21
22
  const logger = config.logger;
22
23
  const stream = {};
23
- const evalUrlPrefix = baseUrl + '/eval/' + environment;
24
+ const evalUrlPrefix = appendUrlPath(baseUrl, '/eval/' + environment);
24
25
  const useReport = config.useReport;
25
26
  const withReasons = config.evaluationReasons;
26
27
  const streamReconnectDelay = config.streamReconnectDelay;
@@ -98,7 +99,7 @@ function Stream(platform, config, environment, diagnosticsAccumulator) {
98
99
  options.body = JSON.stringify(user);
99
100
  } else {
100
101
  // if we can't do REPORT, fall back to the old ping-based stream
101
- url = baseUrl + '/ping/' + environment;
102
+ url = appendUrlPath(baseUrl, '/ping/' + environment);
102
103
  query = '';
103
104
  }
104
105
  } else {
@@ -1,6 +1,5 @@
1
1
  ---
2
2
  globals:
3
- VERSION: true
4
3
  sinon: true
5
4
  expect: true
6
5
  requestor: true
@@ -1,3 +1,4 @@
1
+ import * as messages from '../messages';
1
2
  import * as utils from '../utils';
2
3
 
3
4
  import { AsyncQueue, eventSink, sleepAsync, withCloseable } from 'launchdarkly-js-test-helpers';
@@ -650,6 +651,38 @@ describe('LDClient streaming', () => {
650
651
  });
651
652
  });
652
653
 
654
+ describe('emits error if malformed JSON is received', () => {
655
+ const doMalformedJsonEventTest = async (eventName, eventData) => {
656
+ // First, verify that there isn't an unhandled rejection if we're not listening for an error
657
+ await withClientAndServer({}, async client => {
658
+ await client.waitForInitialization();
659
+ client.setStreaming(true);
660
+
661
+ const stream = await expectStreamConnecting(fullStreamUrlWithUser);
662
+ stream.eventSource.mockEmit(eventName, { data: eventData });
663
+ });
664
+
665
+ // Then, repeat the test using a listener to observe the error event
666
+ await withClientAndServer({}, async client => {
667
+ const errorEvents = new AsyncQueue();
668
+ client.on('error', e => errorEvents.add(e));
669
+
670
+ await client.waitForInitialization();
671
+ client.setStreaming(true);
672
+
673
+ const stream = await expectStreamConnecting(fullStreamUrlWithUser);
674
+ stream.eventSource.mockEmit(eventName, { data: eventData });
675
+
676
+ const e = await errorEvents.take();
677
+ expect(e.message).toEqual(messages.invalidData());
678
+ });
679
+ };
680
+
681
+ it('in put event', async () => doMalformedJsonEventTest('put', '{no'));
682
+ it('in patch event', async () => doMalformedJsonEventTest('patch', '{no'));
683
+ it('in delete event', async () => doMalformedJsonEventTest('delete', '{no'));
684
+ });
685
+
653
686
  it('reconnects to stream if the user changes', async () => {
654
687
  const user2 = { key: 'user2' };
655
688
  const encodedUser2 = 'eyJrZXkiOiJ1c2VyMiJ9';
@@ -2,7 +2,6 @@ import * as LDClient from '../index';
2
2
  import * as messages from '../messages';
3
3
  import * as utils from '../utils';
4
4
 
5
- import semverCompare from 'semver-compare';
6
5
  import { eventSink, promisifySingle, sleepAsync, withCloseable, AsyncQueue } from 'launchdarkly-js-test-helpers';
7
6
 
8
7
  import { respond, respondJson } from './mockHttp';
@@ -106,13 +105,6 @@ describe('LDClient', () => {
106
105
  });
107
106
  });
108
107
 
109
- it('should contain package version', () => {
110
- const version = LDClient.version;
111
- // All client bundles above 1.0.7 should contain package version
112
- const result = semverCompare(version, '1.0.6');
113
- expect(result).toEqual(1);
114
- });
115
-
116
108
  async function verifyCustomHeader(sendLDHeaders, shouldGetHeaders) {
117
109
  await withServers(async (baseConfig, pollServer) => {
118
110
  await withClient(user, { ...baseConfig, sendLDHeaders }, async client => {
@@ -1,7 +1,7 @@
1
1
  import { DiagnosticsAccumulator } from '../diagnosticEvents';
2
2
  import * as messages from '../messages';
3
3
  import Stream from '../Stream';
4
- import { getLDHeaders } from '../utils';
4
+ import { getLDHeaders } from '../headers';
5
5
 
6
6
  import { sleepAsync } from 'launchdarkly-js-test-helpers';
7
7
  import EventSource from './EventSource-mock';
@@ -214,4 +214,43 @@ describe('configuration', () => {
214
214
  expect(config.extraFunctionOption).toBe(fn);
215
215
  await listener.expectError(messages.wrongOptionType('extraNumericOptionWithoutDefault', 'number', 'string'));
216
216
  });
217
+
218
+ it('handles a valid application id', async () => {
219
+ const listener = errorListener();
220
+ const configIn = { application: { id: 'test-application' } };
221
+ expect(configuration.validate(configIn, listener.emitter, null, listener.logger).application.id).toEqual(
222
+ 'test-application'
223
+ );
224
+ });
225
+
226
+ it('logs a warning with an invalid application id', async () => {
227
+ const listener = errorListener();
228
+ const configIn = { application: { id: 'test #$#$#' } };
229
+ expect(configuration.validate(configIn, listener.emitter, null, listener.logger).application.id).toBeUndefined();
230
+ await listener.expectWarningOnly(messages.invalidTagValue('application.id'));
231
+ });
232
+
233
+ it('handles a valid application version', async () => {
234
+ const listener = errorListener();
235
+ const configIn = { application: { version: 'test-version' } };
236
+ expect(configuration.validate(configIn, listener.emitter, null, listener.logger).application.version).toEqual(
237
+ 'test-version'
238
+ );
239
+ });
240
+
241
+ it('logs a warning with an invalid application version', async () => {
242
+ const listener = errorListener();
243
+ const configIn = { application: { version: 'test #$#$#' } };
244
+ expect(
245
+ configuration.validate(configIn, listener.emitter, null, listener.logger).application.version
246
+ ).toBeUndefined();
247
+ await listener.expectWarningOnly(messages.invalidTagValue('application.version'));
248
+ });
249
+
250
+ it('includes application id and version in tags when present', async () => {
251
+ expect(configuration.getTags({ application: { id: 'test-id', version: 'test-version' } })).toEqual({
252
+ 'application-id': ['test-id'],
253
+ 'application-version': ['test-version'],
254
+ });
255
+ });
217
256
  });
@@ -0,0 +1,117 @@
1
+ import { getLDHeaders, transformHeaders } from '../headers';
2
+ import { getLDUserAgentString } from '../utils';
3
+ import * as stubPlatform from './stubPlatform';
4
+
5
+ describe('getLDHeaders', () => {
6
+ it('sends no headers unless sendLDHeaders is true', () => {
7
+ const platform = stubPlatform.defaults();
8
+ const headers = getLDHeaders(platform, {});
9
+ expect(headers).toEqual({});
10
+ });
11
+
12
+ it('adds user-agent header', () => {
13
+ const platform = stubPlatform.defaults();
14
+ const headers = getLDHeaders(platform, { sendLDHeaders: true });
15
+ expect(headers).toMatchObject({ 'User-Agent': getLDUserAgentString(platform) });
16
+ });
17
+
18
+ it('adds user-agent header with custom name', () => {
19
+ const platform = stubPlatform.defaults();
20
+ platform.userAgentHeaderName = 'X-Fake-User-Agent';
21
+ const headers = getLDHeaders(platform, { sendLDHeaders: true });
22
+ expect(headers).toMatchObject({ 'X-Fake-User-Agent': getLDUserAgentString(platform) });
23
+ });
24
+
25
+ it('adds wrapper info if specified, without version', () => {
26
+ const platform = stubPlatform.defaults();
27
+ const headers = getLDHeaders(platform, { sendLDHeaders: true, wrapperName: 'FakeSDK' });
28
+ expect(headers).toMatchObject({
29
+ 'User-Agent': getLDUserAgentString(platform),
30
+ 'X-LaunchDarkly-Wrapper': 'FakeSDK',
31
+ });
32
+ });
33
+
34
+ it('adds wrapper info if specified, with version', () => {
35
+ const platform = stubPlatform.defaults();
36
+ const headers = getLDHeaders(platform, { sendLDHeaders: true, wrapperName: 'FakeSDK', wrapperVersion: '9.9' });
37
+ expect(headers).toMatchObject({
38
+ 'User-Agent': getLDUserAgentString(platform),
39
+ 'X-LaunchDarkly-Wrapper': 'FakeSDK/9.9',
40
+ });
41
+ });
42
+
43
+ it('sets the X-LaunchDarkly-Tags header with valid id and version.', () => {
44
+ const platform = stubPlatform.defaults();
45
+ const headers = getLDHeaders(platform, {
46
+ sendLDHeaders: true,
47
+ application: {
48
+ id: 'test-application',
49
+ version: 'test-version',
50
+ },
51
+ });
52
+ expect(headers).toMatchObject({
53
+ 'User-Agent': getLDUserAgentString(platform),
54
+ 'x-launchdarkly-tags': 'application-id/test-application application-version/test-version',
55
+ });
56
+ });
57
+
58
+ it('sets the X-LaunchDarkly-Tags header with just application id', () => {
59
+ const platform = stubPlatform.defaults();
60
+ const headers = getLDHeaders(platform, {
61
+ sendLDHeaders: true,
62
+ application: {
63
+ id: 'test-application',
64
+ },
65
+ });
66
+ expect(headers).toMatchObject({
67
+ 'User-Agent': getLDUserAgentString(platform),
68
+ 'x-launchdarkly-tags': 'application-id/test-application',
69
+ });
70
+ });
71
+
72
+ it('sets the X-LaunchDarkly-Tags header with just application version.', () => {
73
+ const platform = stubPlatform.defaults();
74
+ const headers = getLDHeaders(platform, {
75
+ sendLDHeaders: true,
76
+ application: {
77
+ version: 'test-version',
78
+ },
79
+ });
80
+ expect(headers).toMatchObject({
81
+ 'User-Agent': getLDUserAgentString(platform),
82
+ 'x-launchdarkly-tags': 'application-version/test-version',
83
+ });
84
+ });
85
+ });
86
+
87
+ describe('transformHeaders', () => {
88
+ it('does not modify the headers if the option is not available', () => {
89
+ const inputHeaders = { a: '1', b: '2' };
90
+ const headers = transformHeaders(inputHeaders, {});
91
+ expect(headers).toEqual(inputHeaders);
92
+ });
93
+
94
+ it('modifies the headers if the option has a transform', () => {
95
+ const inputHeaders = { c: '3', d: '4' };
96
+ const outputHeaders = { c: '9', d: '4', e: '5' };
97
+ const headerTransform = input => {
98
+ const output = { ...input };
99
+ output['c'] = '9';
100
+ output['e'] = '5';
101
+ return output;
102
+ };
103
+ const headers = transformHeaders(inputHeaders, { requestHeaderTransform: headerTransform });
104
+ expect(headers).toEqual(outputHeaders);
105
+ });
106
+
107
+ it('cannot mutate the input header object', () => {
108
+ const inputHeaders = { f: '6' };
109
+ const expectedInputHeaders = { f: '6' };
110
+ const headerMutate = input => {
111
+ input['f'] = '7'; // eslint-disable-line no-param-reassign
112
+ return input;
113
+ };
114
+ transformHeaders(inputHeaders, { requestHeaderTransform: headerMutate });
115
+ expect(inputHeaders).toEqual(expectedInputHeaders);
116
+ });
117
+ });
@@ -1,7 +1,6 @@
1
1
  import {
2
+ appendUrlPath,
2
3
  base64URLEncode,
3
- getLDHeaders,
4
- transformHeaders,
5
4
  getLDUserAgentString,
6
5
  wrapPromiseCallback,
7
6
  chunkUserEventsForUrl,
@@ -10,6 +9,13 @@ import {
10
9
  import * as stubPlatform from './stubPlatform';
11
10
 
12
11
  describe('utils', () => {
12
+ it('appendUrlPath', () => {
13
+ expect(appendUrlPath('http://base', '/path')).toEqual('http://base/path');
14
+ expect(appendUrlPath('http://base', 'path')).toEqual('http://base/path');
15
+ expect(appendUrlPath('http://base/', '/path')).toEqual('http://base/path');
16
+ expect(appendUrlPath('http://base/', '/path')).toEqual('http://base/path');
17
+ });
18
+
13
19
  describe('wrapPromiseCallback', () => {
14
20
  it('should resolve to the value', done => {
15
21
  const promise = wrapPromiseCallback(Promise.resolve('woohoo'));
@@ -48,83 +54,12 @@ describe('utils', () => {
48
54
  });
49
55
  });
50
56
 
51
- describe('getLDHeaders', () => {
52
- it('sends no headers unless sendLDHeaders is true', () => {
53
- const platform = stubPlatform.defaults();
54
- const headers = getLDHeaders(platform, {});
55
- expect(headers).toEqual({});
56
- });
57
-
58
- it('adds user-agent header', () => {
59
- const platform = stubPlatform.defaults();
60
- const headers = getLDHeaders(platform, { sendLDHeaders: true });
61
- expect(headers).toMatchObject({ 'User-Agent': getLDUserAgentString(platform) });
62
- });
63
-
64
- it('adds user-agent header with custom name', () => {
65
- const platform = stubPlatform.defaults();
66
- platform.userAgentHeaderName = 'X-Fake-User-Agent';
67
- const headers = getLDHeaders(platform, { sendLDHeaders: true });
68
- expect(headers).toMatchObject({ 'X-Fake-User-Agent': getLDUserAgentString(platform) });
69
- });
70
-
71
- it('adds wrapper info if specified, without version', () => {
72
- const platform = stubPlatform.defaults();
73
- const headers = getLDHeaders(platform, { sendLDHeaders: true, wrapperName: 'FakeSDK' });
74
- expect(headers).toMatchObject({
75
- 'User-Agent': getLDUserAgentString(platform),
76
- 'X-LaunchDarkly-Wrapper': 'FakeSDK',
77
- });
78
- });
79
-
80
- it('adds wrapper info if specified, with version', () => {
81
- const platform = stubPlatform.defaults();
82
- const headers = getLDHeaders(platform, { sendLDHeaders: true, wrapperName: 'FakeSDK', wrapperVersion: '9.9' });
83
- expect(headers).toMatchObject({
84
- 'User-Agent': getLDUserAgentString(platform),
85
- 'X-LaunchDarkly-Wrapper': 'FakeSDK/9.9',
86
- });
87
- });
88
- });
89
-
90
- describe('transformHeaders', () => {
91
- it('does not modify the headers if the option is not available', () => {
92
- const inputHeaders = { a: '1', b: '2' };
93
- const headers = transformHeaders(inputHeaders, {});
94
- expect(headers).toEqual(inputHeaders);
95
- });
96
-
97
- it('modifies the headers if the option has a transform', () => {
98
- const inputHeaders = { c: '3', d: '4' };
99
- const outputHeaders = { c: '9', d: '4', e: '5' };
100
- const headerTransform = input => {
101
- const output = { ...input };
102
- output['c'] = '9';
103
- output['e'] = '5';
104
- return output;
105
- };
106
- const headers = transformHeaders(inputHeaders, { requestHeaderTransform: headerTransform });
107
- expect(headers).toEqual(outputHeaders);
108
- });
109
-
110
- it('cannot mutate the input header object', () => {
111
- const inputHeaders = { f: '6' };
112
- const expectedInputHeaders = { f: '6' };
113
- const headerMutate = input => {
114
- input['f'] = '7'; // eslint-disable-line no-param-reassign
115
- return input;
116
- };
117
- transformHeaders(inputHeaders, { requestHeaderTransform: headerMutate });
118
- expect(inputHeaders).toEqual(expectedInputHeaders);
119
- });
120
- });
121
-
122
57
  describe('getLDUserAgentString', () => {
123
- it('uses platform user-agent and package version by default', () => {
58
+ it('uses platform user-agent and unknown version by default', () => {
124
59
  const platform = stubPlatform.defaults();
125
60
  platform.version = undefined;
126
61
  const ua = getLDUserAgentString(platform);
127
- expect(ua).toEqual('stubClient/' + VERSION);
62
+ expect(ua).toEqual('stubClient/?');
128
63
  });
129
64
 
130
65
  it('uses platform user-agent and platform version if provided', () => {
@@ -38,8 +38,38 @@ const baseOptionDefs = {
38
38
  wrapperVersion: { type: 'string' },
39
39
  stateProvider: { type: 'object' }, // not a public option, used internally
40
40
  autoAliasingOptOut: { default: false },
41
+ application: { validator: applicationConfigValidator },
41
42
  };
42
43
 
44
+ /**
45
+ * Expression to validate characters that are allowed in tag keys and values.
46
+ */
47
+ const allowedTagCharacters = /^(\w|\.|-)+$/;
48
+
49
+ /**
50
+ * Verify that a value meets the requirements for a tag value.
51
+ * @param {Object} config
52
+ * @param {string} tagValue
53
+ */
54
+ function validateTagValue(name, config, tagValue, logger) {
55
+ if (typeof tagValue !== 'string' || !tagValue.match(allowedTagCharacters)) {
56
+ logger.warn(messages.invalidTagValue(name));
57
+ return undefined;
58
+ }
59
+ return tagValue;
60
+ }
61
+
62
+ function applicationConfigValidator(name, config, value, logger) {
63
+ const validated = {};
64
+ if (value.id) {
65
+ validated.id = validateTagValue(`${name}.id`, config, value.id, logger);
66
+ }
67
+ if (value.version) {
68
+ validated.version = validateTagValue(`${name}.version`, config, value.version, logger);
69
+ }
70
+ return validated;
71
+ }
72
+
43
73
  function validate(options, emitter, extraOptionDefs, logger) {
44
74
  const optionDefs = utils.extend({ logger: { default: logger } }, baseOptionDefs, extraOptionDefs);
45
75
 
@@ -104,7 +134,15 @@ function validate(options, emitter, extraOptionDefs, logger) {
104
134
  reportArgumentError(messages.unknownOption(name));
105
135
  } else {
106
136
  const expectedType = optionDef.type || typeDescForValue(optionDef.default);
107
- if (expectedType !== 'any') {
137
+ const validator = optionDef.validator;
138
+ if (validator) {
139
+ const validated = validator(name, config, config[name], logger);
140
+ if (validated !== undefined) {
141
+ ret[name] = validated;
142
+ } else {
143
+ delete ret[name];
144
+ }
145
+ } else if (expectedType !== 'any') {
108
146
  const allowedTypes = expectedType.split('|');
109
147
  const actualType = typeDescForValue(value);
110
148
  if (allowedTypes.indexOf(actualType) < 0) {
@@ -145,7 +183,30 @@ function validate(options, emitter, extraOptionDefs, logger) {
145
183
  return config;
146
184
  }
147
185
 
186
+ /**
187
+ * Get tags for the specified configuration.
188
+ *
189
+ * If any additional tags are added to the configuration, then the tags from
190
+ * this method should be extended with those.
191
+ * @param {Object} config The already valiated configuration.
192
+ * @returns {Object} The tag configuration.
193
+ */
194
+ function getTags(config) {
195
+ const tags = {};
196
+ if (config) {
197
+ if (config.application && config.application.id !== undefined && config.application.id !== null) {
198
+ tags['application-id'] = [config.application.id];
199
+ }
200
+ if (config.application && config.application.version !== undefined && config.application.id !== null) {
201
+ tags['application-version'] = [config.application.version];
202
+ }
203
+ }
204
+
205
+ return tags;
206
+ }
207
+
148
208
  module.exports = {
149
209
  baseOptionDefs,
150
210
  validate,
211
+ getTags,
151
212
  };
@@ -5,6 +5,7 @@ const { v1: uuidv1 } = require('uuid');
5
5
 
6
6
  const { baseOptionDefs } = require('./configuration');
7
7
  const messages = require('./messages');
8
+ const { appendUrlPath } = require('./utils');
8
9
 
9
10
  function DiagnosticId(sdkKey) {
10
11
  const ret = {
@@ -80,7 +81,7 @@ function DiagnosticsManager(
80
81
  ) {
81
82
  const combinedMode = !!platform.diagnosticUseCombinedEvent;
82
83
  const localStorageKey = 'ld:' + environmentId + ':$diagnostics';
83
- const diagnosticEventsUrl = config.eventsUrl + '/events/diagnostic/' + environmentId;
84
+ const diagnosticEventsUrl = appendUrlPath(config.eventsUrl, '/events/diagnostic/' + environmentId);
84
85
  const periodicInterval = config.diagnosticRecordingInterval;
85
86
  const acc = accumulator;
86
87
  const initialEventSamplingInterval = 4; // used only in combined mode - see start()
package/src/errors.js CHANGED
@@ -18,6 +18,7 @@ const LDInvalidUserError = createCustomError('LaunchDarklyInvalidUserError');
18
18
  const LDInvalidEventKeyError = createCustomError('LaunchDarklyInvalidEventKeyError');
19
19
  const LDInvalidArgumentError = createCustomError('LaunchDarklyInvalidArgumentError');
20
20
  const LDFlagFetchError = createCustomError('LaunchDarklyFlagFetchError');
21
+ const LDInvalidDataError = createCustomError('LaunchDarklyInvalidDataError');
21
22
 
22
23
  function isHttpErrorRecoverable(status) {
23
24
  if (status >= 400 && status < 500) {
@@ -32,6 +33,7 @@ module.exports = {
32
33
  LDInvalidUserError,
33
34
  LDInvalidEventKeyError,
34
35
  LDInvalidArgumentError,
36
+ LDInvalidDataError,
35
37
  LDFlagFetchError,
36
38
  isHttpErrorRecoverable,
37
39
  };
package/src/headers.js ADDED
@@ -0,0 +1,38 @@
1
+ const { getLDUserAgentString } = require('./utils');
2
+ const configuration = require('./configuration');
3
+
4
+ function getLDHeaders(platform, options) {
5
+ if (options && !options.sendLDHeaders) {
6
+ return {};
7
+ }
8
+ const h = {};
9
+ h[platform.userAgentHeaderName || 'User-Agent'] = getLDUserAgentString(platform);
10
+ if (options && options.wrapperName) {
11
+ h['X-LaunchDarkly-Wrapper'] = options.wrapperVersion
12
+ ? options.wrapperName + '/' + options.wrapperVersion
13
+ : options.wrapperName;
14
+ }
15
+ const tags = configuration.getTags(options);
16
+ const tagKeys = Object.keys(tags);
17
+ if (tagKeys.length) {
18
+ h['x-launchdarkly-tags'] = tagKeys
19
+ .sort()
20
+ .flatMap(
21
+ key => (Array.isArray(tags[key]) ? tags[key].sort().map(value => `${key}/${value}`) : [`${key}/${tags[key]}`])
22
+ )
23
+ .join(' ');
24
+ }
25
+ return h;
26
+ }
27
+
28
+ function transformHeaders(headers, options) {
29
+ if (!options || !options.requestHeaderTransform) {
30
+ return headers;
31
+ }
32
+ return options.requestHeaderTransform({ ...headers });
33
+ }
34
+
35
+ module.exports = {
36
+ getLDHeaders,
37
+ transformHeaders,
38
+ };
package/src/index.js CHANGED
@@ -386,6 +386,14 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) {
386
386
  if (!ident.getUser()) {
387
387
  return;
388
388
  }
389
+ const tryParseData = jsonData => {
390
+ try {
391
+ return JSON.parse(jsonData);
392
+ } catch (err) {
393
+ emitter.maybeReportError(new errors.LDInvalidDataError(messages.invalidData()));
394
+ return undefined;
395
+ }
396
+ };
389
397
  stream.connect(ident.getUser(), hash, {
390
398
  ping: function() {
391
399
  logger.debug(messages.debugStreamPing());
@@ -404,12 +412,20 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) {
404
412
  });
405
413
  },
406
414
  put: function(e) {
407
- const data = JSON.parse(e.data);
415
+ const data = tryParseData(e.data);
416
+ if (!data) {
417
+ return;
418
+ }
408
419
  logger.debug(messages.debugStreamPut());
409
- replaceAllFlags(data); // don't wait for this Promise to be resolved
420
+ replaceAllFlags(data);
421
+ // Don't wait for this Promise to be resolved; note that replaceAllFlags is guaranteed
422
+ // never to have an unhandled rejection
410
423
  },
411
424
  patch: function(e) {
412
- const data = JSON.parse(e.data);
425
+ const data = tryParseData(e.data);
426
+ if (!data) {
427
+ return;
428
+ }
413
429
  // If both the flag and the patch have a version property, then the patch version must be
414
430
  // greater than the flag version for us to accept the patch. If either one has no version
415
431
  // then the patch always succeeds.
@@ -432,7 +448,10 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) {
432
448
  }
433
449
  },
434
450
  delete: function(e) {
435
- const data = JSON.parse(e.data);
451
+ const data = tryParseData(e.data);
452
+ if (!data) {
453
+ return;
454
+ }
436
455
  if (!flags[data.key] || flags[data.key].version < data.version) {
437
456
  logger.debug(messages.debugStreamDelete(data.key));
438
457
  const mods = {};
@@ -760,13 +779,10 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) {
760
779
  };
761
780
  }
762
781
 
763
- const version = VERSION;
764
-
765
782
  module.exports = {
766
783
  initialize,
767
784
  commonBasicLogger,
768
785
  errors,
769
786
  messages,
770
787
  utils,
771
- version,
772
788
  };
package/src/messages.js CHANGED
@@ -68,6 +68,10 @@ const invalidUser = function() {
68
68
  return 'Invalid user specified.' + docLink;
69
69
  };
70
70
 
71
+ const invalidData = function() {
72
+ return 'Invalid data received from LaunchDarkly; connection may have been interrupted';
73
+ };
74
+
71
75
  const bootstrapOldFormat = function() {
72
76
  return (
73
77
  'LaunchDarkly client was initialized with bootstrap data that did not include flag metadata. ' +
@@ -176,6 +180,8 @@ const debugPostingDiagnosticEvent = function(event) {
176
180
  return 'sending diagnostic event (' + event.kind + ')';
177
181
  };
178
182
 
183
+ const invalidTagValue = name => `Config option "${name}" must only contain letters, numbers, ., _ or -.`;
184
+
179
185
  module.exports = {
180
186
  bootstrapInvalid,
181
187
  bootstrapOldFormat,
@@ -201,7 +207,9 @@ module.exports = {
201
207
  httpUnavailable,
202
208
  identifyDisabled,
203
209
  invalidContentType,
210
+ invalidData,
204
211
  invalidKey,
212
+ invalidTagValue,
205
213
  invalidUser,
206
214
  localStorageUnavailable,
207
215
  networkError,
package/src/utils.js CHANGED
@@ -3,6 +3,13 @@ const fastDeepEqual = require('fast-deep-equal');
3
3
 
4
4
  const userAttrsToStringify = ['key', 'secondary', 'ip', 'country', 'email', 'firstName', 'lastName', 'avatar', 'name'];
5
5
 
6
+ function appendUrlPath(baseUrl, path) {
7
+ // Ensure that URL concatenation is done correctly regardless of whether the
8
+ // base URL has a trailing slash or not.
9
+ const trimBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
10
+ return trimBaseUrl + (path.startsWith('/') ? '' : '/') + path;
11
+ }
12
+
6
13
  // See http://ecmanaut.blogspot.com/2006/07/encoding-decoding-utf8-in-javascript.html
7
14
  function btoa(s) {
8
15
  const escaped = unescape(encodeURIComponent(s));
@@ -146,31 +153,10 @@ function chunkUserEventsForUrl(maxLength, events) {
146
153
  }
147
154
 
148
155
  function getLDUserAgentString(platform) {
149
- const version = platform.version || VERSION;
156
+ const version = platform.version || '?';
150
157
  return platform.userAgent + '/' + version;
151
158
  }
152
159
 
153
- function getLDHeaders(platform, options) {
154
- if (options && !options.sendLDHeaders) {
155
- return {};
156
- }
157
- const h = {};
158
- h[platform.userAgentHeaderName || 'User-Agent'] = getLDUserAgentString(platform);
159
- if (options && options.wrapperName) {
160
- h['X-LaunchDarkly-Wrapper'] = options.wrapperVersion
161
- ? options.wrapperName + '/' + options.wrapperVersion
162
- : options.wrapperName;
163
- }
164
- return h;
165
- }
166
-
167
- function transformHeaders(headers, options) {
168
- if (!options || !options.requestHeaderTransform) {
169
- return headers;
170
- }
171
- return options.requestHeaderTransform({ ...headers });
172
- }
173
-
174
160
  function extend(...objects) {
175
161
  return objects.reduce((acc, obj) => ({ ...acc, ...obj }), {});
176
162
  }
@@ -196,18 +182,17 @@ function sanitizeUser(user) {
196
182
  }
197
183
 
198
184
  module.exports = {
185
+ appendUrlPath,
199
186
  base64URLEncode,
200
187
  btoa,
201
188
  chunkUserEventsForUrl,
202
189
  clone,
203
190
  deepEquals,
204
191
  extend,
205
- getLDHeaders,
206
192
  getLDUserAgentString,
207
193
  objectHasOwnProperty,
208
194
  onNextTick,
209
195
  sanitizeUser,
210
- transformHeaders,
211
196
  transformValuesToVersionedValues,
212
197
  transformVersionedValuesToValues,
213
198
  wrapPromiseCallback,
package/test-types.ts CHANGED
@@ -4,8 +4,6 @@
4
4
 
5
5
  import * as ld from 'launchdarkly-js-sdk-common';
6
6
 
7
- var ver: string = ld.version;
8
-
9
7
  var userWithKeyOnly: ld.LDUser = { key: 'user' };
10
8
  var anonUserWithNoKey: ld.LDUser = { anonymous: true };
11
9
  var anonUserWithKey: ld.LDUser = { key: 'anon-user', anonymous: true };
@@ -49,7 +47,11 @@ var allBaseOptions: ld.LDOptionsBase = {
49
47
  sendEventsOnlyForVariation: true,
50
48
  flushInterval: 1,
51
49
  streamReconnectDelay: 1,
52
- logger: logger
50
+ logger: logger,
51
+ application: {
52
+ version: 'version',
53
+ id: 'id'
54
+ }
53
55
  };
54
56
 
55
57
  var client: ld.LDClientBase = {} as ld.LDClientBase; // wouldn't do this in real life, it's just so the following statements will compile
package/typings.d.ts CHANGED
@@ -2,12 +2,6 @@
2
2
  * Basic LaunchDarkly JavaScript client interfaces, shared between the browser SDK and the Electron SDK.
3
3
  */
4
4
  declare module 'launchdarkly-js-sdk-common' {
5
-
6
- /**
7
- * The current version string of the SDK.
8
- */
9
- export const version: string;
10
-
11
5
  /**
12
6
  * The types of values a feature flag can have.
13
7
  *
@@ -121,10 +115,14 @@ declare module 'launchdarkly-js-sdk-common' {
121
115
  /**
122
116
  * Whether or not to include custom HTTP headers when requesting flags from LaunchDarkly.
123
117
  *
124
- * Currently these are used to track what version of the SDK is active. This defaults to true
125
- * (custom headers will be sent). One reason you might want to set it to false is that the presence
126
- * of custom headers causes browsers to make an extra OPTIONS request (a CORS preflight check)
127
- * before each flag request, which could affect performance.
118
+ * These are used to send metadata about the SDK (such as the version). They
119
+ * are also used to send the application.id and application.version set in
120
+ * the options.
121
+ *
122
+ * This defaults to true (custom headers will be sent). One reason you might
123
+ * want to set it to false is that the presence of custom headers causes
124
+ * browsers to make an extra OPTIONS request (a CORS preflight check) before
125
+ * each flag request, which could affect performance.
128
126
  */
129
127
  sendLDHeaders?: boolean;
130
128
 
@@ -261,6 +259,31 @@ declare module 'launchdarkly-js-sdk-common' {
261
259
  * The default value is `false`.
262
260
  */
263
261
  autoAliasingOptOut?: boolean;
262
+
263
+ /**
264
+ * Information about the application where the LaunchDarkly SDK is running.
265
+ */
266
+ application?: {
267
+ /**
268
+ * A unique identifier representing the application where the LaunchDarkly SDK is running.
269
+ *
270
+ * This can be specified as any string value as long as it only uses the following characters: ASCII letters,
271
+ * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored.
272
+ *
273
+ * Example: `authentication-service`
274
+ */
275
+ id?: string;
276
+
277
+ /**
278
+ * A unique identifier representing the version of the application where the LaunchDarkly SDK is running.
279
+ *
280
+ * This can be specified as any string value as long as it only uses the following characters: ASCII letters,
281
+ * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored.
282
+ *
283
+ * Example: `1.0.0` (standard version string) or `abcdef` (sha prefix)
284
+ */
285
+ version?: string;
286
+ }
264
287
  }
265
288
 
266
289
  /**