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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/src/EventProcessor.js +16 -9
- package/src/EventSummarizer.js +1 -0
- package/src/MultiEventSummarizer.js +76 -0
- package/src/__tests__/LDClient-test.js +3 -1
- package/src/__tests__/MultiEventSummarizer-test.js +158 -0
- package/src/__tests__/canonicalize-test.js +86 -0
- package/src/__tests__/context-test.js +331 -2
- package/src/__tests__/stubPlatform.js +9 -0
- package/src/__tests__/testdata/LICENSE.txt +13 -0
- package/src/__tests__/testdata/VENDOR.txt +2 -0
- package/src/__tests__/testdata/input/arrays.json +8 -0
- package/src/__tests__/testdata/input/french.json +6 -0
- package/src/__tests__/testdata/input/structures.json +8 -0
- package/src/__tests__/testdata/input/unicode.json +3 -0
- package/src/__tests__/testdata/input/values.json +5 -0
- package/src/__tests__/testdata/input/weird.json +11 -0
- package/src/__tests__/testdata/output/arrays.json +1 -0
- package/src/__tests__/testdata/output/french.json +1 -0
- package/src/__tests__/testdata/output/structures.json +1 -0
- package/src/__tests__/testdata/output/unicode.json +1 -0
- package/src/__tests__/testdata/output/values.json +1 -0
- package/src/__tests__/testdata/output/weird.json +1 -0
- package/src/canonicalize.js +41 -0
- package/src/context.js +40 -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
package/src/EventProcessor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const EventSender = require('./EventSender');
|
|
2
|
-
const
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
summary.
|
|
129
|
-
|
|
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
|
package/src/EventSummarizer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,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
|
};
|