launchdarkly-js-sdk-common 5.6.0-beta.1 → 5.7.0-beta.1

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "5.5.1"
2
+ ".": "5.6.0"
3
3
  }
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
+ ## [5.6.0](https://github.com/launchdarkly/js-sdk-common/compare/5.5.1...5.6.0) (2025-04-29)
6
+
7
+
8
+ ### Features
9
+
10
+ * Add support for plugins. ([#124](https://github.com/launchdarkly/js-sdk-common/issues/124)) ([e0544c1](https://github.com/launchdarkly/js-sdk-common/commit/e0544c13d94b1088aebc4f6852743e408f5f77af))
11
+ * Add support for the afterTrack stage for hooks. ([#123](https://github.com/launchdarkly/js-sdk-common/issues/123)) ([f7bebeb](https://github.com/launchdarkly/js-sdk-common/commit/f7bebebc15fc0ac718ebe167291215992b8ee6f5))
12
+
5
13
  ## [5.5.1](https://github.com/launchdarkly/js-sdk-common/compare/5.5.0...5.5.1) (2025-04-25)
6
14
 
7
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchdarkly-js-sdk-common",
3
- "version": "5.6.0-beta.1",
3
+ "version": "5.7.0-beta.1",
4
4
  "description": "LaunchDarkly SDK for JavaScript - common code",
5
5
  "author": "LaunchDarkly <team@launchdarkly.com>",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  const EventSender = require('./EventSender');
2
- const EventSummarizer = require('./EventSummarizer');
2
+ const MultiEventSummarizer = require('./MultiEventSummarizer');
3
3
  const ContextFilter = require('./ContextFilter');
4
4
  const errors = require('./errors');
5
5
  const messages = require('./messages');
@@ -17,8 +17,8 @@ function EventProcessor(
17
17
  const processor = {};
18
18
  const eventSender = sender || EventSender(platform, environmentId, options);
19
19
  const mainEventsUrl = utils.appendUrlPath(options.eventsUrl, '/events/bulk/' + environmentId);
20
- const summarizer = EventSummarizer();
21
20
  const contextFilter = ContextFilter(options);
21
+ const summarizer = MultiEventSummarizer(contextFilter, () => platform.hasherFactory('sha256'));
22
22
  const samplingInterval = options.samplingInterval;
23
23
  const eventCapacity = options.eventCapacity;
24
24
  const flushInterval = options.flushInterval;
@@ -117,17 +117,24 @@ function EventProcessor(
117
117
  }
118
118
  };
119
119
 
120
- processor.flush = function() {
120
+ processor.flush = async function() {
121
121
  if (disabled) {
122
122
  return Promise.resolve();
123
123
  }
124
124
  const eventsToSend = queue;
125
- const summary = summarizer.getSummary();
126
- summarizer.clearSummary();
127
- if (summary) {
128
- summary.kind = 'summary';
129
- eventsToSend.push(summary);
130
- }
125
+ const summaries = await summarizer.getSummaries();
126
+
127
+ summaries.forEach(summary => {
128
+ if (Object.keys(summary.features).length) {
129
+ eventsToSend.push(summary);
130
+ }
131
+ });
132
+ // const summary = summarizer.getSummary();
133
+ // summarizer.clearSummary();
134
+ // if (summary) {
135
+ // summary.kind = 'summary';
136
+ // eventsToSend.push(summary);
137
+ // }
131
138
  if (diagnosticsAccumulator) {
132
139
  // For diagnostic events, we record how many events were in the queue at the last flush (since "how
133
140
  // many events happened to be in the queue at the moment we decided to send a diagnostic event" would
@@ -89,6 +89,7 @@ function EventSummarizer() {
89
89
  startDate,
90
90
  endDate,
91
91
  features: flagsOut,
92
+ kind: 'summary',
92
93
  };
93
94
  };
94
95
 
@@ -0,0 +1,76 @@
1
+ import { hashContext } from './context';
2
+ import EventSummarizer from './EventSummarizer';
3
+ /**
4
+ *
5
+ * @param {{filter: (context: any) => any}} contextFilter
6
+ * @param {() => {update: (value: string) => void, digest: (format: string) => Promise<string>}} hasherFactory
7
+ */
8
+ function MultiEventSummarizer(contextFilter, hasherFactory) {
9
+ let summarizers = {};
10
+ let contexts = {};
11
+ const pendingPromises = [];
12
+
13
+ /**
14
+ * Summarize the given event.
15
+ * @param {{
16
+ * kind: string,
17
+ * context?: any,
18
+ * }} event
19
+ */
20
+ function summarizeEvent(event) {
21
+ // This will execute asynchronously, which means that a flush could happen before the event
22
+ // is summarized. When that happens, then the event will just be in the next batch of summaries.
23
+ const promise = (async () => {
24
+ if (event.kind === 'feature') {
25
+ const hash = await hashContext(event.context, hasherFactory());
26
+ if (!hash) {
27
+ return;
28
+ }
29
+
30
+ let summarizer = summarizers[hash];
31
+ if (!summarizer) {
32
+ summarizers[hash] = EventSummarizer();
33
+ summarizer = summarizers[hash];
34
+ contexts[hash] = event.context;
35
+ }
36
+
37
+ summarizer.summarizeEvent(event);
38
+ }
39
+ })();
40
+ pendingPromises.push(promise);
41
+ promise.finally(() => {
42
+ const index = pendingPromises.indexOf(promise);
43
+ if (index !== -1) {
44
+ pendingPromises.splice(index, 1);
45
+ }
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Get the summaries of the events that have been summarized.
51
+ * @returns {any[]}
52
+ */
53
+ async function getSummaries() {
54
+ // Wait for any pending summarizations to complete
55
+ // Additional tasks queued while waiting will not be waited for.
56
+ await Promise.all([...pendingPromises]);
57
+
58
+ const summarizersToFlush = summarizers;
59
+ const contextsForSummaries = contexts;
60
+
61
+ summarizers = {};
62
+ contexts = {};
63
+ return Object.entries(summarizersToFlush).map(([hash, summarizer]) => {
64
+ const summary = summarizer.getSummary();
65
+ summary.context = contextFilter.filter(contextsForSummaries[hash]);
66
+ return summary;
67
+ });
68
+ }
69
+
70
+ return {
71
+ summarizeEvent,
72
+ getSummaries,
73
+ };
74
+ }
75
+
76
+ module.exports = MultiEventSummarizer;
@@ -672,7 +672,9 @@ describe('LDClient', () => {
672
672
  await client.waitForInitialization(5);
673
673
  });
674
674
 
675
- expect(eventsServer.requests.length()).toEqual(1);
675
+ // Flushing is an async operation, so we cannot ensure that the requests are made by
676
+ // the time we reach this point. If we await the nextRequest(), then it will catch
677
+ // whatever was flushed.
676
678
  const req = await eventsServer.nextRequest();
677
679
  const data = JSON.parse(req.body);
678
680
  expect(data.length).toEqual(1);
@@ -0,0 +1,158 @@
1
+ const MultiEventSummarizer = require('../MultiEventSummarizer');
2
+ const ContextFilter = require('../ContextFilter');
3
+
4
+ function mockHasher() {
5
+ let state = '';
6
+ return {
7
+ update: input => {
8
+ state += input;
9
+ },
10
+ digest: () => state,
11
+ };
12
+ }
13
+
14
+ function makeEvent(key, version, variation, value, defaultVal, context) {
15
+ return {
16
+ kind: 'feature',
17
+ creationDate: 1000,
18
+ key: key,
19
+ version: version,
20
+ context: context,
21
+ variation: variation,
22
+ value: value,
23
+ default: defaultVal,
24
+ };
25
+ }
26
+
27
+ describe('with mocked crypto and hasher', () => {
28
+ let summarizer;
29
+ let contextFilter;
30
+
31
+ beforeEach(() => {
32
+ contextFilter = ContextFilter(false, []);
33
+ summarizer = MultiEventSummarizer(contextFilter, mockHasher);
34
+ });
35
+
36
+ it('creates new summarizer for new context hash', async () => {
37
+ const context = { kind: 'user', key: 'user1' };
38
+ const event = { kind: 'feature', context };
39
+
40
+ summarizer.summarizeEvent(event);
41
+
42
+ const summaries = await summarizer.getSummaries();
43
+ expect(summaries).toHaveLength(1);
44
+ });
45
+
46
+ it('uses existing summarizer for same context hash', async () => {
47
+ const context = { kind: 'user', key: 'user1' };
48
+ const event1 = { kind: 'feature', context, value: 'value1' };
49
+ const event2 = { kind: 'feature', context, value: 'value2' };
50
+
51
+ summarizer.summarizeEvent(event1);
52
+ summarizer.summarizeEvent(event2);
53
+
54
+ const summaries = await summarizer.getSummaries();
55
+ expect(summaries).toHaveLength(1);
56
+ });
57
+
58
+ it('ignores non-feature events', async () => {
59
+ const context = { kind: 'user', key: 'user1' };
60
+ const event = { kind: 'identify', context };
61
+
62
+ summarizer.summarizeEvent(event);
63
+
64
+ const summaries = await summarizer.getSummaries();
65
+ expect(summaries).toHaveLength(0);
66
+ });
67
+
68
+ it('handles multiple different contexts', async () => {
69
+ const context1 = { kind: 'user', key: 'user1' };
70
+ const context2 = { kind: 'user', key: 'user2' };
71
+ const event1 = { kind: 'feature', context: context1 };
72
+ const event2 = { kind: 'feature', context: context2 };
73
+
74
+ summarizer.summarizeEvent(event1);
75
+ summarizer.summarizeEvent(event2);
76
+
77
+ const summaries = await summarizer.getSummaries();
78
+ expect(summaries).toHaveLength(2);
79
+ });
80
+
81
+ it('automatically clears summaries when summarized', async () => {
82
+ const context = { kind: 'user', key: 'user1' };
83
+ const event = { kind: 'feature', context };
84
+
85
+ summarizer.summarizeEvent(event);
86
+
87
+ const summariesA = await summarizer.getSummaries();
88
+ const summariesB = await summarizer.getSummaries();
89
+ expect(summariesA).toHaveLength(1);
90
+ expect(summariesB).toHaveLength(0);
91
+ });
92
+
93
+ it('increments counters for feature events across multiple contexts', async () => {
94
+ const context1 = { kind: 'user', key: 'user1' };
95
+ const context2 = { kind: 'user', key: 'user2' };
96
+
97
+ // Events for context1 (using values 100-199)
98
+ const event1 = makeEvent('key1', 11, 1, 100, 111, context1);
99
+ const event2 = makeEvent('key1', 11, 2, 150, 111, context1);
100
+ const event3 = makeEvent('key2', 22, 1, 199, 222, context1);
101
+
102
+ // Events for context2 (using values 200-299)
103
+ const event4 = makeEvent('key1', 11, 1, 200, 211, context2);
104
+ const event5 = makeEvent('key1', 11, 2, 250, 211, context2);
105
+ const event6 = makeEvent('key2', 22, 1, 299, 222, context2);
106
+
107
+ summarizer.summarizeEvent(event1);
108
+ summarizer.summarizeEvent(event2);
109
+ summarizer.summarizeEvent(event3);
110
+ summarizer.summarizeEvent(event4);
111
+ summarizer.summarizeEvent(event5);
112
+ summarizer.summarizeEvent(event6);
113
+
114
+ const summaries = await summarizer.getSummaries();
115
+ expect(summaries).toHaveLength(2);
116
+
117
+ // Sort summaries by context key to make assertions consistent
118
+ summaries.sort((a, b) => a.context.key.localeCompare(b.context.key));
119
+
120
+ // Verify first context's summary (user1, values 100-199)
121
+ const summary1 = summaries[0];
122
+ summary1.features.key1.counters.sort((a, b) => a.value - b.value);
123
+ expect(summary1.features).toEqual({
124
+ key1: {
125
+ contextKinds: ['user'],
126
+ default: 111,
127
+ counters: [
128
+ { value: 100, variation: 1, version: 11, count: 1 },
129
+ { value: 150, variation: 2, version: 11, count: 1 },
130
+ ],
131
+ },
132
+ key2: {
133
+ contextKinds: ['user'],
134
+ default: 222,
135
+ counters: [{ value: 199, variation: 1, version: 22, count: 1 }],
136
+ },
137
+ });
138
+
139
+ // Verify second context's summary (user2, values 200-299)
140
+ const summary2 = summaries[1];
141
+ summary2.features.key1.counters.sort((a, b) => a.value - b.value);
142
+ expect(summary2.features).toEqual({
143
+ key1: {
144
+ contextKinds: ['user'],
145
+ default: 211,
146
+ counters: [
147
+ { value: 200, variation: 1, version: 11, count: 1 },
148
+ { value: 250, variation: 2, version: 11, count: 1 },
149
+ ],
150
+ },
151
+ key2: {
152
+ contextKinds: ['user'],
153
+ default: 222,
154
+ counters: [{ value: 299, variation: 1, version: 22, count: 1 }],
155
+ },
156
+ });
157
+ });
158
+ });
@@ -0,0 +1,86 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const canonicalize = require('../canonicalize');
5
+
6
+ // Get the test file pairs
7
+ const testInputDir = path.join(__dirname, 'testdata', 'input');
8
+ const testOutputDir = path.join(__dirname, 'testdata', 'output');
9
+ const testFiles = fs.readdirSync(testInputDir);
10
+
11
+ it.each(testFiles)('should correctly canonicalize %s', filename => {
12
+ // Load the input and expected output files
13
+ const inputPath = path.join(testInputDir, filename);
14
+ const outputPath = path.join(testOutputDir, filename);
15
+
16
+ const inputData = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
17
+ const expectedOutput = fs.readFileSync(outputPath, 'utf8');
18
+
19
+ // Apply the canonicalize function
20
+ const result = canonicalize(inputData);
21
+
22
+ // Compare results
23
+ expect(result).toEqual(expectedOutput);
24
+ });
25
+
26
+ it('handles basic arrays', () => {
27
+ const input = [];
28
+ const expected = '[]';
29
+ const result = canonicalize(input);
30
+ expect(result).toEqual(expected);
31
+ });
32
+
33
+ it('handles arrays of null/undefined', () => {
34
+ const input = [null, undefined];
35
+ const expected = '[null,null]';
36
+ const result = canonicalize(input);
37
+ expect(result).toEqual(expected);
38
+ });
39
+
40
+ it('handles objects with numeric keys', () => {
41
+ const input = {
42
+ 1: 'one',
43
+ 2: 'two',
44
+ };
45
+ const expected = '{"1":"one","2":"two"}';
46
+ const result = canonicalize(input);
47
+ expect(result).toEqual(expected);
48
+ });
49
+
50
+ it('handles objects with undefined values', () => {
51
+ const input = {
52
+ a: 'b',
53
+ c: undefined,
54
+ };
55
+ const expected = '{"a":"b"}';
56
+ const result = canonicalize(input);
57
+ expect(result).toEqual(expected);
58
+ });
59
+
60
+ it('handles an object with a symbol value', () => {
61
+ const input = {
62
+ a: 'b',
63
+ c: Symbol('c'),
64
+ };
65
+ const expected = '{"a":"b"}';
66
+ const result = canonicalize(input);
67
+ expect(result).toEqual(expected);
68
+ });
69
+
70
+ it('handles an object with a symbol key', () => {
71
+ const input = {
72
+ a: 'b',
73
+ [Symbol('c')]: 'd',
74
+ };
75
+ const expected = '{"a":"b"}';
76
+ const result = canonicalize(input);
77
+ expect(result).toEqual(expected);
78
+ });
79
+
80
+ it('should throw an error for objects with cycles', () => {
81
+ const a = {};
82
+ const b = { a };
83
+ a.b = b;
84
+
85
+ expect(() => canonicalize(a)).toThrow('Cycle detected');
86
+ });
@@ -1,4 +1,4 @@
1
- const { checkContext, getContextKeys, getContextKinds, getCanonicalKey } = require('../context');
1
+ const { checkContext, getContextKeys, getContextKinds, getCanonicalKey, hashContext } = require('../context');
2
2
 
3
3
  describe.each([{ key: 'test' }, { kind: 'user', key: 'test' }, { kind: 'multi', user: { key: 'test' } }])(
4
4
  'given a context which contains a single kind',
@@ -147,7 +147,7 @@ describe('getContextKeys', () => {
147
147
  expect(keys).toEqual({ user: 'test-user-key' });
148
148
  });
149
149
 
150
- it.only('ignores empty string and null keys from multi context', () => {
150
+ it('ignores empty string and null keys from multi context', () => {
151
151
  const context = {
152
152
  kind: 'multi',
153
153
  user: {
@@ -199,3 +199,332 @@ describe('getContextKeys', () => {
199
199
  expect(keys).toEqual({});
200
200
  });
201
201
  });
202
+
203
+ function mockHasher() {
204
+ let state = '';
205
+ return {
206
+ update: input => {
207
+ state += input;
208
+ },
209
+ digest: () => state,
210
+ };
211
+ }
212
+
213
+ it('hashes two equal contexts the same', async () => {
214
+ const a = {
215
+ kind: 'multi',
216
+ org: {
217
+ key: 'testKey',
218
+ name: 'testName',
219
+ cat: 'calico',
220
+ dog: 'lab',
221
+ anonymous: true,
222
+ _meta: {
223
+ privateAttributes: ['/a/b/c', 'cat', 'custom/dog'],
224
+ },
225
+ },
226
+ customer: {
227
+ key: 'testKey',
228
+ name: 'testName',
229
+ bird: 'party parrot',
230
+ chicken: 'hen',
231
+ },
232
+ };
233
+
234
+ const b = {
235
+ kind: 'multi',
236
+ org: {
237
+ key: 'testKey',
238
+ name: 'testName',
239
+ cat: 'calico',
240
+ dog: 'lab',
241
+ anonymous: true,
242
+ _meta: {
243
+ privateAttributes: ['/a/b/c', 'cat', 'custom/dog'],
244
+ },
245
+ },
246
+ customer: {
247
+ key: 'testKey',
248
+ name: 'testName',
249
+ bird: 'party parrot',
250
+ chicken: 'hen',
251
+ },
252
+ };
253
+ expect(await hashContext(a, mockHasher())).toEqual(await hashContext(b, mockHasher()));
254
+ });
255
+
256
+ it('handles shared references without getting stuck', async () => {
257
+ const sharedObject = { value: 'shared' };
258
+ const context = {
259
+ kind: 'multi',
260
+ org: {
261
+ key: 'testKey',
262
+ shared: sharedObject,
263
+ },
264
+ user: {
265
+ key: 'testKey',
266
+ shared: sharedObject,
267
+ },
268
+ };
269
+
270
+ const hash = await hashContext(context, mockHasher());
271
+ expect(hash).toBeDefined();
272
+ });
273
+
274
+ it('returns undefined for contexts with cycles', async () => {
275
+ const cyclicObject = { value: 'cyclic' };
276
+ cyclicObject.self = cyclicObject;
277
+
278
+ const context = {
279
+ kind: 'user',
280
+ key: 'testKey',
281
+ cyclic: cyclicObject,
282
+ };
283
+
284
+ expect(await hashContext(context, mockHasher())).toBeUndefined();
285
+ });
286
+
287
+ it('handles nested objects correctly', async () => {
288
+ const context = {
289
+ kind: 'user',
290
+ key: 'testKey',
291
+ nested: {
292
+ level1: {
293
+ level2: {
294
+ value: 'deep',
295
+ },
296
+ },
297
+ },
298
+ };
299
+
300
+ const hash = await hashContext(context, mockHasher());
301
+ expect(hash).toBeDefined();
302
+ });
303
+
304
+ it('handles arrays correctly', async () => {
305
+ const context = {
306
+ kind: 'user',
307
+ key: 'testKey',
308
+ array: [1, 2, 3],
309
+ nestedArray: [
310
+ [1, 2],
311
+ [3, 4],
312
+ ],
313
+ };
314
+
315
+ const hash = await hashContext(context, mockHasher());
316
+ expect(hash).toBeDefined();
317
+ });
318
+
319
+ it('handles primitive values correctly', async () => {
320
+ const context = {
321
+ kind: 'user',
322
+ key: 'testKey',
323
+ string: 'test',
324
+ number: 42,
325
+ boolean: true,
326
+ nullValue: null,
327
+ undefinedValue: undefined,
328
+ };
329
+
330
+ const hash = await hashContext(context, mockHasher());
331
+ expect(hash).toBeDefined();
332
+ });
333
+
334
+ it('includes private attributes in hash calculation', async () => {
335
+ const baseContext = {
336
+ kind: 'user',
337
+ key: 'testKey',
338
+ name: 'testName',
339
+ nested: {
340
+ value: 'testValue',
341
+ },
342
+ };
343
+
344
+ const contextWithPrivate = {
345
+ ...baseContext,
346
+ _meta: {
347
+ privateAttributes: ['name', 'nested/value'],
348
+ },
349
+ };
350
+
351
+ const hashWithPrivate = await hashContext(contextWithPrivate, mockHasher());
352
+ const hashWithoutPrivate = await hashContext(baseContext, mockHasher());
353
+
354
+ // The hashes should be different because private attributes are included in the hash
355
+ expect(hashWithPrivate).not.toEqual(hashWithoutPrivate);
356
+ });
357
+
358
+ it('uses the keys of attributes in the hash', async () => {
359
+ const a = {
360
+ kind: 'user',
361
+ key: 'testKey',
362
+ a: 'b',
363
+ };
364
+
365
+ const b = {
366
+ kind: 'user',
367
+ key: 'testKey',
368
+ b: 'b',
369
+ };
370
+
371
+ const hashA = await hashContext(a, mockHasher());
372
+ const hashB = await hashContext(b, mockHasher());
373
+ expect(hashA).not.toBe(hashB);
374
+ });
375
+
376
+ it('uses the keys of nested objects inside the hash', async () => {
377
+ const a = {
378
+ kind: 'user',
379
+ key: 'testKey',
380
+ nested: {
381
+ level1: {
382
+ level2: {
383
+ value: 'deep',
384
+ },
385
+ },
386
+ },
387
+ };
388
+
389
+ const b = {
390
+ kind: 'user',
391
+ key: 'testKey',
392
+ nested: {
393
+ sub1: {
394
+ sub2: {
395
+ value: 'deep',
396
+ },
397
+ },
398
+ },
399
+ };
400
+
401
+ const hashA = await hashContext(a, mockHasher());
402
+ const hashB = await hashContext(b, mockHasher());
403
+ expect(hashA).not.toBe(hashB);
404
+ });
405
+
406
+ it('uses the values of nested array in calculations', async () => {
407
+ const a = {
408
+ kind: 'user',
409
+ key: 'testKey',
410
+ array: [1, 2, 3],
411
+ nestedArray: [
412
+ [1, 2],
413
+ [3, 4],
414
+ ],
415
+ };
416
+
417
+ const b = {
418
+ kind: 'user',
419
+ key: 'testKey',
420
+ array: [1, 2, 3],
421
+ nestedArray: [
422
+ [2, 1],
423
+ [3, 4],
424
+ ],
425
+ };
426
+
427
+ const hashA = await hashContext(a, mockHasher());
428
+ const hashB = await hashContext(b, mockHasher());
429
+ expect(hashA).not.toBe(hashB);
430
+ });
431
+
432
+ it('uses the values of nested objects inside the hash', async () => {
433
+ const a = {
434
+ kind: 'user',
435
+ key: 'testKey',
436
+ nested: {
437
+ level1: {
438
+ level2: {
439
+ value: 'deep',
440
+ },
441
+ },
442
+ },
443
+ };
444
+
445
+ const b = {
446
+ kind: 'user',
447
+ key: 'testKey',
448
+ nested: {
449
+ level1: {
450
+ level2: {
451
+ value: 'deeper',
452
+ },
453
+ },
454
+ },
455
+ };
456
+
457
+ const hashA = await hashContext(a, mockHasher());
458
+ const hashB = await hashContext(b, mockHasher());
459
+ expect(hashA).not.toBe(hashB);
460
+ });
461
+
462
+ it('hashes _meta in attributes', async () => {
463
+ const a = {
464
+ kind: 'user',
465
+ key: 'testKey',
466
+ nested: {
467
+ level1: {
468
+ level2: {
469
+ _meta: { test: 'a' },
470
+ },
471
+ },
472
+ },
473
+ };
474
+
475
+ const b = {
476
+ kind: 'user',
477
+ key: 'testKey',
478
+ nested: {
479
+ level1: {
480
+ level2: {
481
+ _meta: { test: 'b' },
482
+ },
483
+ },
484
+ },
485
+ };
486
+
487
+ const hashA = await hashContext(a, mockHasher());
488
+ const hashB = await hashContext(b, mockHasher());
489
+ expect(hashA).not.toBe(hashB);
490
+ });
491
+
492
+ it('produces the same value for the given context', async () => {
493
+ // This isn't so much a test as it is a detection of change.
494
+ // If this test failed, and you didn't expect it, then you probably need to make sure your
495
+ // change makes sense.
496
+ const complexContext = {
497
+ kind: 'multi',
498
+ org: {
499
+ key: 'testKey',
500
+ name: 'testName',
501
+ cat: 'calico',
502
+ dog: 'lab',
503
+ anonymous: true,
504
+ nestedArray: [
505
+ [1, 2],
506
+ [3, 4],
507
+ ],
508
+ _meta: {
509
+ privateAttributes: ['/a/b/c', 'cat', 'custom/dog'],
510
+ },
511
+ },
512
+ customer: {
513
+ key: 'testKey',
514
+ name: 'testName',
515
+ bird: 'party parrot',
516
+ chicken: 'hen',
517
+ nested: {
518
+ level1: {
519
+ level2: {
520
+ value: 'deep',
521
+ _meta: { thisShouldBeInTheHash: true },
522
+ },
523
+ },
524
+ },
525
+ },
526
+ };
527
+ expect(await hashContext(complexContext, mockHasher())).toBe(
528
+ '{"customer":{"bird":"party parrot","chicken":"hen","key":"testKey","name":"testName","nested":{"level1":{"level2":{"_meta":{"thisShouldBeInTheHash":true},"value":"deep"}}}},"kind":"multi","org":{"_meta":{"privateAttributes":["/a/b/c","cat","custom/dog"]},"anonymous":true,"cat":"calico","dog":"lab","key":"testKey","name":"testName","nestedArray":[[1,2],[3,4]]}}'
529
+ );
530
+ });
@@ -45,6 +45,15 @@ export function defaults() {
45
45
  diagnosticPlatformData: { name: 'stub-platform' },
46
46
  getCurrentUrl: () => currentUrl,
47
47
  isDoNotTrack: () => doNotTrack,
48
+ hasherFactory: (/*algorithm*/) => {
49
+ let content = '';
50
+ return {
51
+ update: value => {
52
+ content += value;
53
+ },
54
+ digest: (/*format*/) => content,
55
+ };
56
+ },
48
57
  eventSourceFactory: (url, options) => {
49
58
  const es = new EventSource(url);
50
59
  es.options = options;
@@ -0,0 +1,13 @@
1
+ Copyright 2018 Anders Rundgren
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ https://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,2 @@
1
+ Test data originally from:
2
+ https://github.com/cyberphone/json-canonicalization/tree/master/testdata
@@ -0,0 +1,8 @@
1
+ [
2
+ 56,
3
+ {
4
+ "d": true,
5
+ "10": null,
6
+ "1": [ ]
7
+ }
8
+ ]
@@ -0,0 +1,6 @@
1
+ {
2
+ "peach": "This sorting order",
3
+ "péché": "is wrong according to French",
4
+ "pêche": "but canonicalization MUST",
5
+ "sin": "ignore locale"
6
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "1": {"f": {"f": "hi","F": 5} ,"\n": 56.0},
3
+ "10": { },
4
+ "": "empty",
5
+ "a": { },
6
+ "111": [ {"e": "yes","E": "no" } ],
7
+ "A": { }
8
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "Unnormalized Unicode":"A\u030a"
3
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "numbers": [333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001],
3
+ "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/",
4
+ "literals": [null, true, false]
5
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "\u20ac": "Euro Sign",
3
+ "\r": "Carriage Return",
4
+ "\u000a": "Newline",
5
+ "1": "One",
6
+ "\u0080": "Control\u007f",
7
+ "\ud83d\ude02": "Smiley",
8
+ "\u00f6": "Latin Small Letter O With Diaeresis",
9
+ "\ufb33": "Hebrew Letter Dalet With Dagesh",
10
+ "</script>": "Browser Challenge"
11
+ }
@@ -0,0 +1 @@
1
+ [56,{"1":[],"10":null,"d":true}]
@@ -0,0 +1 @@
1
+ {"peach":"This sorting order","péché":"is wrong according to French","pêche":"but canonicalization MUST","sin":"ignore locale"}
@@ -0,0 +1 @@
1
+ {"":"empty","1":{"\n":56,"f":{"F":5,"f":"hi"}},"10":{},"111":[{"E":"no","e":"yes"}],"A":{},"a":{}}
@@ -0,0 +1 @@
1
+ {"Unnormalized Unicode":"Å"}
@@ -0,0 +1 @@
1
+ {"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\u000f\nA'B\"\\\\\"/"}
@@ -0,0 +1 @@
1
+ {"\n":"Newline","\r":"Carriage Return","1":"One","</script>":"Browser Challenge","€":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😂":"Smiley","דּ":"Hebrew Letter Dalet With Dagesh"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Given some object to serialize product a canonicalized JSON string.
3
+ * https://www.rfc-editor.org/rfc/rfc8785.html
4
+ *
5
+ * We do not support custom toJSON methods on objects. Objects should be limited to basic types.
6
+ *
7
+ * @param {any} object The object to serialize.
8
+ * @param {any[]?} visited The list of objects that have already been visited to avoid cycles.
9
+ * @returns {string} The canonicalized JSON string.
10
+ */
11
+ function canonicalize(object, visited = []) {
12
+ // For JavaScript the default JSON serialization will produce canonicalized output for basic types.
13
+ if (object === null || typeof object !== 'object') {
14
+ return JSON.stringify(object);
15
+ }
16
+
17
+ if (visited.includes(object)) {
18
+ throw new Error('Cycle detected');
19
+ }
20
+
21
+ if (Array.isArray(object)) {
22
+ const values = object
23
+ .map(item => canonicalize(item, [...visited, object]))
24
+ .map(item => (item === undefined ? 'null' : item));
25
+ return `[${values.join(',')}]`;
26
+ }
27
+
28
+ const values = Object.keys(object)
29
+ .sort()
30
+ .map(key => {
31
+ const value = canonicalize(object[key], [...visited, object]);
32
+ if (value !== undefined) {
33
+ return `${JSON.stringify(key)}:${value}`;
34
+ }
35
+ return undefined;
36
+ })
37
+ .filter(item => item !== undefined);
38
+ return `{${values.join(',')}}`;
39
+ }
40
+
41
+ module.exports = canonicalize;
package/src/context.js CHANGED
@@ -1,10 +1,11 @@
1
+ const { commonBasicLogger } = require('./loggers');
2
+ const canonicalize = require('./canonicalize');
3
+
1
4
  /**
2
5
  * Validate a context kind.
3
6
  * @param {string} kind
4
7
  * @returns true if the kind is valid.
5
8
  */
6
- const { commonBasicLogger } = require('./loggers');
7
-
8
9
  function validKind(kind) {
9
10
  return typeof kind === 'string' && kind !== 'kind' && kind.match(/^(\w|\.|-)+$/);
10
11
  }
@@ -44,7 +45,7 @@ function checkContext(context, allowLegacyKey) {
44
45
  /**
45
46
  * For a given context get a list of context kinds.
46
47
  * @param {Object} context
47
- * @returns A list of kinds in the context.
48
+ * @returns {string[]} A list of kinds in the context.
48
49
  */
49
50
  function getContextKinds(context) {
50
51
  if (context) {
@@ -126,9 +127,45 @@ function getContextKeys(context, logger = commonBasicLogger()) {
126
127
  return keys;
127
128
  }
128
129
 
130
+ /**
131
+ * Hash the given context using the provided hasher.
132
+ * This implementation can produce different hashes for equivalent contexts.
133
+ *
134
+ * For example:
135
+ * A legacy user and a single-kind context of user kind that are equivalent, will hash differently.
136
+ * A multi-context with one kind, and the single context with that kind are equivalent, but will hash differently.
137
+ * Two equivalent contexts, with private attributes that are defined in different orders, will hash differently.
138
+ *
139
+ * @param {Object} context
140
+ * @param {{update: (value: string) => void, digest: (format: string) => Promise<string>}} hasher
141
+ * @returns {Promise<string | undefined>} The hash of the context, or undefined if the context is invalid.
142
+ */
143
+ function hashContext(context, hasher) {
144
+ // In js-core we have legacy and non-legacy contexts hash the same. This implementation does not support that.
145
+ // Because this implementation directly uses the user-provided context and doesn't manipulate it.
146
+ // The js-core implementation is more conceptually correct, but it isn't a practical requirement.
147
+
148
+ // This implementation additionally doesn't produce the same hash for an equivalent multi-context with one kind, and
149
+ // the single context with that kind.
150
+ if (!checkContext(context)) {
151
+ return undefined;
152
+ }
153
+
154
+ try {
155
+ const canonicalized = canonicalize(context);
156
+
157
+ hasher.update(canonicalized);
158
+
159
+ return hasher.digest('hex');
160
+ } catch {
161
+ return undefined;
162
+ }
163
+ }
164
+
129
165
  module.exports = {
130
166
  checkContext,
131
167
  getContextKeys,
132
168
  getContextKinds,
133
169
  getCanonicalKey,
170
+ hashContext,
134
171
  };