@xyd-js/host 0.0.0-build
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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/app/debug-null.tsx +3 -0
- package/app/docPaths.ts +69 -0
- package/app/entry.client.tsx +58 -0
- package/app/entry.server.tsx +68 -0
- package/app/pathRoutes.ts +86 -0
- package/app/public.ts +43 -0
- package/app/raw.ts +31 -0
- package/app/robots.ts +18 -0
- package/app/root.tsx +579 -0
- package/app/routes.ts +35 -0
- package/app/scripts/abtesting.ts +279 -0
- package/app/scripts/bannerHeight.ts +14 -0
- package/app/scripts/colorSchemeScript.ts +21 -0
- package/app/scripts/growthbook.js +3574 -0
- package/app/scripts/launchdarkly.js +2 -0
- package/app/scripts/openfeature.growthbook.js +692 -0
- package/app/scripts/openfeature.js +1715 -0
- package/app/scripts/openfeature.launchdarkly.js +877 -0
- package/app/scripts/testFeatureFlag.ts +39 -0
- package/app/sitemap.ts +40 -0
- package/app/types/raw.d.ts +4 -0
- package/auto-imports.d.ts +10 -0
- package/package.json +41 -0
- package/plugins/README.md +1 -0
- package/postcss.config.cjs +5 -0
- package/react-router.config.ts +94 -0
- package/src/auto-imports.d.ts +29 -0
- package/tsconfig.json +28 -0
- package/types.d.ts +8 -0
- package/vite.config.ts +8 -0
|
@@ -0,0 +1,3574 @@
|
|
|
1
|
+
var growthbook = (function (exports) {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const polyfills$1 = {
|
|
5
|
+
fetch: globalThis.fetch ? globalThis.fetch.bind(globalThis) : undefined,
|
|
6
|
+
SubtleCrypto: globalThis.crypto ? globalThis.crypto.subtle : undefined,
|
|
7
|
+
EventSource: globalThis.EventSource
|
|
8
|
+
};
|
|
9
|
+
function getPolyfills() {
|
|
10
|
+
return polyfills$1;
|
|
11
|
+
}
|
|
12
|
+
function hashFnv32a(str) {
|
|
13
|
+
let hval = 0x811c9dc5;
|
|
14
|
+
const l = str.length;
|
|
15
|
+
for (let i = 0; i < l; i++) {
|
|
16
|
+
hval ^= str.charCodeAt(i);
|
|
17
|
+
hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24);
|
|
18
|
+
}
|
|
19
|
+
return hval >>> 0;
|
|
20
|
+
}
|
|
21
|
+
function hash(seed, value, version) {
|
|
22
|
+
// New unbiased hashing algorithm
|
|
23
|
+
if (version === 2) {
|
|
24
|
+
return hashFnv32a(hashFnv32a(seed + value) + "") % 10000 / 10000;
|
|
25
|
+
}
|
|
26
|
+
// Original biased hashing algorithm (keep for backwards compatibility)
|
|
27
|
+
if (version === 1) {
|
|
28
|
+
return hashFnv32a(value + seed) % 1000 / 1000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Unknown hash version
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function getEqualWeights(n) {
|
|
35
|
+
if (n <= 0) return [];
|
|
36
|
+
return new Array(n).fill(1 / n);
|
|
37
|
+
}
|
|
38
|
+
function inRange(n, range) {
|
|
39
|
+
return n >= range[0] && n < range[1];
|
|
40
|
+
}
|
|
41
|
+
function inNamespace(hashValue, namespace) {
|
|
42
|
+
const n = hash("__" + namespace[0], hashValue, 1);
|
|
43
|
+
if (n === null) return false;
|
|
44
|
+
return n >= namespace[1] && n < namespace[2];
|
|
45
|
+
}
|
|
46
|
+
function chooseVariation(n, ranges) {
|
|
47
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
48
|
+
if (inRange(n, ranges[i])) {
|
|
49
|
+
return i;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return -1;
|
|
53
|
+
}
|
|
54
|
+
function getUrlRegExp(regexString) {
|
|
55
|
+
try {
|
|
56
|
+
const escaped = regexString.replace(/([^\\])\//g, "$1\\/");
|
|
57
|
+
return new RegExp(escaped);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.error(e);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function isURLTargeted(url, targets) {
|
|
64
|
+
if (!targets.length) return false;
|
|
65
|
+
let hasIncludeRules = false;
|
|
66
|
+
let isIncluded = false;
|
|
67
|
+
for (let i = 0; i < targets.length; i++) {
|
|
68
|
+
const match = _evalURLTarget(url, targets[i].type, targets[i].pattern);
|
|
69
|
+
if (targets[i].include === false) {
|
|
70
|
+
if (match) return false;
|
|
71
|
+
} else {
|
|
72
|
+
hasIncludeRules = true;
|
|
73
|
+
if (match) isIncluded = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return isIncluded || !hasIncludeRules;
|
|
77
|
+
}
|
|
78
|
+
function _evalSimpleUrlPart(actual, pattern, isPath) {
|
|
79
|
+
try {
|
|
80
|
+
// Escape special regex characters and change wildcard `_____` to `.*`
|
|
81
|
+
let escaped = pattern.replace(/[*.+?^${}()|[\]\\]/g, "\\$&").replace(/_____/g, ".*");
|
|
82
|
+
if (isPath) {
|
|
83
|
+
// When matching pathname, make leading/trailing slashes optional
|
|
84
|
+
escaped = "\\/?" + escaped.replace(/(^\/|\/$)/g, "") + "\\/?";
|
|
85
|
+
}
|
|
86
|
+
const regex = new RegExp("^" + escaped + "$", "i");
|
|
87
|
+
return regex.test(actual);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function _evalSimpleUrlTarget(actual, pattern) {
|
|
93
|
+
try {
|
|
94
|
+
// If a protocol is missing, but a host is specified, add `https://` to the front
|
|
95
|
+
// Use "_____" as the wildcard since `*` is not a valid hostname in some browsers
|
|
96
|
+
const expected = new URL(pattern.replace(/^([^:/?]*)\./i, "https://$1.").replace(/\*/g, "_____"), "https://_____");
|
|
97
|
+
|
|
98
|
+
// Compare each part of the URL separately
|
|
99
|
+
const comps = [[actual.host, expected.host, false], [actual.pathname, expected.pathname, true]];
|
|
100
|
+
// We only want to compare hashes if it's explicitly being targeted
|
|
101
|
+
if (expected.hash) {
|
|
102
|
+
comps.push([actual.hash, expected.hash, false]);
|
|
103
|
+
}
|
|
104
|
+
expected.searchParams.forEach((v, k) => {
|
|
105
|
+
comps.push([actual.searchParams.get(k) || "", v, false]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// If any comparisons fail, the whole thing fails
|
|
109
|
+
return !comps.some(data => !_evalSimpleUrlPart(data[0], data[1], data[2]));
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function _evalURLTarget(url, type, pattern) {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = new URL(url, "https://_");
|
|
117
|
+
if (type === "regex") {
|
|
118
|
+
const regex = getUrlRegExp(pattern);
|
|
119
|
+
if (!regex) return false;
|
|
120
|
+
return regex.test(parsed.href) || regex.test(parsed.href.substring(parsed.origin.length));
|
|
121
|
+
} else if (type === "simple") {
|
|
122
|
+
return _evalSimpleUrlTarget(parsed, pattern);
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function getBucketRanges(numVariations, coverage, weights) {
|
|
130
|
+
coverage = coverage === undefined ? 1 : coverage;
|
|
131
|
+
|
|
132
|
+
// Make sure coverage is within bounds
|
|
133
|
+
if (coverage < 0) {
|
|
134
|
+
coverage = 0;
|
|
135
|
+
} else if (coverage > 1) {
|
|
136
|
+
coverage = 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Default to equal weights if missing or invalid
|
|
140
|
+
const equal = getEqualWeights(numVariations);
|
|
141
|
+
weights = weights || equal;
|
|
142
|
+
if (weights.length !== numVariations) {
|
|
143
|
+
weights = equal;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// If weights don't add up to 1 (or close to it), default to equal weights
|
|
147
|
+
const totalWeight = weights.reduce((w, sum) => sum + w, 0);
|
|
148
|
+
if (totalWeight < 0.99 || totalWeight > 1.01) {
|
|
149
|
+
weights = equal;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Covert weights to ranges
|
|
153
|
+
let cumulative = 0;
|
|
154
|
+
return weights.map(w => {
|
|
155
|
+
const start = cumulative;
|
|
156
|
+
cumulative += w;
|
|
157
|
+
return [start, start + coverage * w];
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function getQueryStringOverride(id, url, numVariations) {
|
|
161
|
+
if (!url) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const search = url.split("?")[1];
|
|
165
|
+
if (!search) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const match = search.replace(/#.*/, "") // Get rid of anchor
|
|
169
|
+
.split("&") // Split into key/value pairs
|
|
170
|
+
.map(kv => kv.split("=", 2)).filter(_ref => {
|
|
171
|
+
let [k] = _ref;
|
|
172
|
+
return k === id;
|
|
173
|
+
}) // Look for key that matches the experiment id
|
|
174
|
+
.map(_ref2 => {
|
|
175
|
+
let [, v] = _ref2;
|
|
176
|
+
return parseInt(v);
|
|
177
|
+
}); // Parse the value into an integer
|
|
178
|
+
|
|
179
|
+
if (match.length > 0 && match[0] >= 0 && match[0] < numVariations) return match[0];
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
function isIncluded(include) {
|
|
183
|
+
try {
|
|
184
|
+
return include();
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.error(e);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const base64ToBuf = b => Uint8Array.from(atob(b), c => c.charCodeAt(0));
|
|
191
|
+
async function decrypt(encryptedString, decryptionKey, subtle) {
|
|
192
|
+
decryptionKey = decryptionKey || "";
|
|
193
|
+
subtle = subtle || globalThis.crypto && globalThis.crypto.subtle || polyfills$1.SubtleCrypto;
|
|
194
|
+
if (!subtle) {
|
|
195
|
+
throw new Error("No SubtleCrypto implementation found");
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const key = await subtle.importKey("raw", base64ToBuf(decryptionKey), {
|
|
199
|
+
name: "AES-CBC",
|
|
200
|
+
length: 128
|
|
201
|
+
}, true, ["encrypt", "decrypt"]);
|
|
202
|
+
const [iv, cipherText] = encryptedString.split(".");
|
|
203
|
+
const plainTextBuffer = await subtle.decrypt({
|
|
204
|
+
name: "AES-CBC",
|
|
205
|
+
iv: base64ToBuf(iv)
|
|
206
|
+
}, key, base64ToBuf(cipherText));
|
|
207
|
+
return new TextDecoder().decode(plainTextBuffer);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
throw new Error("Failed to decrypt");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
214
|
+
function toString(input) {
|
|
215
|
+
if (typeof input === "string") return input;
|
|
216
|
+
return JSON.stringify(input);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
220
|
+
function paddedVersionString(input) {
|
|
221
|
+
if (typeof input === "number") {
|
|
222
|
+
input = input + "";
|
|
223
|
+
}
|
|
224
|
+
if (!input || typeof input !== "string") {
|
|
225
|
+
input = "0";
|
|
226
|
+
}
|
|
227
|
+
// Remove build info and leading `v` if any
|
|
228
|
+
// Split version into parts (both core version numbers and pre-release tags)
|
|
229
|
+
// "v1.2.3-rc.1+build123" -> ["1","2","3","rc","1"]
|
|
230
|
+
const parts = input.replace(/(^v|\+.*$)/g, "").split(/[-.]/);
|
|
231
|
+
|
|
232
|
+
// If it's SemVer without a pre-release, add `~` to the end
|
|
233
|
+
// ["1","0","0"] -> ["1","0","0","~"]
|
|
234
|
+
// "~" is the largest ASCII character, so this will make "1.0.0" greater than "1.0.0-beta" for example
|
|
235
|
+
if (parts.length === 3) {
|
|
236
|
+
parts.push("~");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Left pad each numeric part with spaces so string comparisons will work ("9">"10", but " 9"<"10")
|
|
240
|
+
// Then, join back together into a single string
|
|
241
|
+
return parts.map(v => v.match(/^[0-9]+$/) ? v.padStart(5, " ") : v).join("-");
|
|
242
|
+
}
|
|
243
|
+
function loadSDKVersion() {
|
|
244
|
+
let version;
|
|
245
|
+
try {
|
|
246
|
+
// @ts-expect-error right-hand value to be replaced by build with string literal
|
|
247
|
+
version = "1.5.1";
|
|
248
|
+
} catch (e) {
|
|
249
|
+
version = "";
|
|
250
|
+
}
|
|
251
|
+
return version;
|
|
252
|
+
}
|
|
253
|
+
function mergeQueryStrings(oldUrl, newUrl) {
|
|
254
|
+
let currUrl;
|
|
255
|
+
let redirectUrl;
|
|
256
|
+
try {
|
|
257
|
+
currUrl = new URL(oldUrl);
|
|
258
|
+
redirectUrl = new URL(newUrl);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
console.error(`Unable to merge query strings: ${e}`);
|
|
261
|
+
return newUrl;
|
|
262
|
+
}
|
|
263
|
+
currUrl.searchParams.forEach((value, key) => {
|
|
264
|
+
// skip if search param already exists in redirectUrl
|
|
265
|
+
if (redirectUrl.searchParams.has(key)) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
redirectUrl.searchParams.set(key, value);
|
|
269
|
+
});
|
|
270
|
+
return redirectUrl.toString();
|
|
271
|
+
}
|
|
272
|
+
function isObj(x) {
|
|
273
|
+
return typeof x === "object" && x !== null;
|
|
274
|
+
}
|
|
275
|
+
function getAutoExperimentChangeType(exp) {
|
|
276
|
+
if (exp.urlPatterns && exp.variations.some(variation => isObj(variation) && "urlRedirect" in variation)) {
|
|
277
|
+
return "redirect";
|
|
278
|
+
} else if (exp.variations.some(variation => isObj(variation) && (variation.domMutations || "js" in variation || "css" in variation))) {
|
|
279
|
+
return "visual";
|
|
280
|
+
}
|
|
281
|
+
return "unknown";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Guarantee the promise always resolves within {timeout} ms
|
|
285
|
+
// Resolved value will be `null` when there's an error or it takes too long
|
|
286
|
+
// Note: The promise will continue running in the background, even if the timeout is hit
|
|
287
|
+
async function promiseTimeout(promise, timeout) {
|
|
288
|
+
return new Promise(resolve => {
|
|
289
|
+
let resolved = false;
|
|
290
|
+
let timer;
|
|
291
|
+
const finish = data => {
|
|
292
|
+
if (resolved) return;
|
|
293
|
+
resolved = true;
|
|
294
|
+
timer && clearTimeout(timer);
|
|
295
|
+
resolve(data || null);
|
|
296
|
+
};
|
|
297
|
+
if (timeout) {
|
|
298
|
+
timer = setTimeout(() => finish(), timeout);
|
|
299
|
+
}
|
|
300
|
+
promise.then(data => finish(data)).catch(() => finish());
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Config settings
|
|
305
|
+
const cacheSettings = {
|
|
306
|
+
// Consider a fetch stale after 1 minute
|
|
307
|
+
staleTTL: 1000 * 60,
|
|
308
|
+
// Max time to keep a fetch in cache (4 hours default)
|
|
309
|
+
maxAge: 1000 * 60 * 60 * 4,
|
|
310
|
+
cacheKey: "gbFeaturesCache",
|
|
311
|
+
backgroundSync: true,
|
|
312
|
+
maxEntries: 10,
|
|
313
|
+
disableIdleStreams: false,
|
|
314
|
+
idleStreamInterval: 20000,
|
|
315
|
+
disableCache: false
|
|
316
|
+
};
|
|
317
|
+
const polyfills = getPolyfills();
|
|
318
|
+
const helpers = {
|
|
319
|
+
fetchFeaturesCall: _ref => {
|
|
320
|
+
let {
|
|
321
|
+
host,
|
|
322
|
+
clientKey,
|
|
323
|
+
headers
|
|
324
|
+
} = _ref;
|
|
325
|
+
return polyfills.fetch(`${host}/api/features/${clientKey}`, {
|
|
326
|
+
headers
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
fetchRemoteEvalCall: _ref2 => {
|
|
330
|
+
let {
|
|
331
|
+
host,
|
|
332
|
+
clientKey,
|
|
333
|
+
payload,
|
|
334
|
+
headers
|
|
335
|
+
} = _ref2;
|
|
336
|
+
const options = {
|
|
337
|
+
method: "POST",
|
|
338
|
+
headers: {
|
|
339
|
+
"Content-Type": "application/json",
|
|
340
|
+
...headers
|
|
341
|
+
},
|
|
342
|
+
body: JSON.stringify(payload)
|
|
343
|
+
};
|
|
344
|
+
return polyfills.fetch(`${host}/api/eval/${clientKey}`, options);
|
|
345
|
+
},
|
|
346
|
+
eventSourceCall: _ref3 => {
|
|
347
|
+
let {
|
|
348
|
+
host,
|
|
349
|
+
clientKey,
|
|
350
|
+
headers
|
|
351
|
+
} = _ref3;
|
|
352
|
+
if (headers) {
|
|
353
|
+
return new polyfills.EventSource(`${host}/sub/${clientKey}`, {
|
|
354
|
+
headers
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return new polyfills.EventSource(`${host}/sub/${clientKey}`);
|
|
358
|
+
},
|
|
359
|
+
startIdleListener: () => {
|
|
360
|
+
let idleTimeout;
|
|
361
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
362
|
+
if (!isBrowser) return;
|
|
363
|
+
const onVisibilityChange = () => {
|
|
364
|
+
if (document.visibilityState === "visible") {
|
|
365
|
+
window.clearTimeout(idleTimeout);
|
|
366
|
+
onVisible();
|
|
367
|
+
} else if (document.visibilityState === "hidden") {
|
|
368
|
+
idleTimeout = window.setTimeout(onHidden, cacheSettings.idleStreamInterval);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
372
|
+
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
373
|
+
},
|
|
374
|
+
stopIdleListener: () => {
|
|
375
|
+
// No-op, replaced by startIdleListener
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
try {
|
|
379
|
+
if (globalThis.localStorage) {
|
|
380
|
+
polyfills.localStorage = globalThis.localStorage;
|
|
381
|
+
}
|
|
382
|
+
} catch (e) {
|
|
383
|
+
// Ignore localStorage errors
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Global state
|
|
387
|
+
const subscribedInstances = new Map();
|
|
388
|
+
let cacheInitialized = false;
|
|
389
|
+
const cache = new Map();
|
|
390
|
+
const activeFetches = new Map();
|
|
391
|
+
const streams = new Map();
|
|
392
|
+
const supportsSSE = new Set();
|
|
393
|
+
|
|
394
|
+
// Public functions
|
|
395
|
+
function setPolyfills(overrides) {
|
|
396
|
+
Object.assign(polyfills, overrides);
|
|
397
|
+
}
|
|
398
|
+
function configureCache(overrides) {
|
|
399
|
+
Object.assign(cacheSettings, overrides);
|
|
400
|
+
if (!cacheSettings.backgroundSync) {
|
|
401
|
+
clearAutoRefresh();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async function clearCache() {
|
|
405
|
+
cache.clear();
|
|
406
|
+
activeFetches.clear();
|
|
407
|
+
clearAutoRefresh();
|
|
408
|
+
cacheInitialized = false;
|
|
409
|
+
await updatePersistentCache();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Get or fetch features and refresh the SDK instance
|
|
413
|
+
async function refreshFeatures(_ref4) {
|
|
414
|
+
let {
|
|
415
|
+
instance,
|
|
416
|
+
timeout,
|
|
417
|
+
skipCache,
|
|
418
|
+
allowStale,
|
|
419
|
+
backgroundSync
|
|
420
|
+
} = _ref4;
|
|
421
|
+
if (!backgroundSync) {
|
|
422
|
+
cacheSettings.backgroundSync = false;
|
|
423
|
+
}
|
|
424
|
+
return fetchFeaturesWithCache({
|
|
425
|
+
instance,
|
|
426
|
+
allowStale,
|
|
427
|
+
timeout,
|
|
428
|
+
skipCache
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Subscribe a GrowthBook instance to feature changes
|
|
433
|
+
function subscribe(instance) {
|
|
434
|
+
const key = getKey(instance);
|
|
435
|
+
const subs = subscribedInstances.get(key) || new Set();
|
|
436
|
+
subs.add(instance);
|
|
437
|
+
subscribedInstances.set(key, subs);
|
|
438
|
+
}
|
|
439
|
+
function unsubscribe(instance) {
|
|
440
|
+
subscribedInstances.forEach(s => s.delete(instance));
|
|
441
|
+
}
|
|
442
|
+
function onHidden() {
|
|
443
|
+
streams.forEach(channel => {
|
|
444
|
+
if (!channel) return;
|
|
445
|
+
channel.state = "idle";
|
|
446
|
+
disableChannel(channel);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
function onVisible() {
|
|
450
|
+
streams.forEach(channel => {
|
|
451
|
+
if (!channel) return;
|
|
452
|
+
if (channel.state !== "idle") return;
|
|
453
|
+
enableChannel(channel);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Private functions
|
|
458
|
+
|
|
459
|
+
async function updatePersistentCache() {
|
|
460
|
+
try {
|
|
461
|
+
if (!polyfills.localStorage) return;
|
|
462
|
+
await polyfills.localStorage.setItem(cacheSettings.cacheKey, JSON.stringify(Array.from(cache.entries())));
|
|
463
|
+
} catch (e) {
|
|
464
|
+
// Ignore localStorage errors
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// SWR wrapper for fetching features. May indirectly or directly start SSE streaming.
|
|
469
|
+
async function fetchFeaturesWithCache(_ref5) {
|
|
470
|
+
let {
|
|
471
|
+
instance,
|
|
472
|
+
allowStale,
|
|
473
|
+
timeout,
|
|
474
|
+
skipCache
|
|
475
|
+
} = _ref5;
|
|
476
|
+
const key = getKey(instance);
|
|
477
|
+
const cacheKey = getCacheKey(instance);
|
|
478
|
+
const now = new Date();
|
|
479
|
+
const minStaleAt = new Date(now.getTime() - cacheSettings.maxAge + cacheSettings.staleTTL);
|
|
480
|
+
await initializeCache();
|
|
481
|
+
const existing = !cacheSettings.disableCache && !skipCache ? cache.get(cacheKey) : undefined;
|
|
482
|
+
if (existing && (allowStale || existing.staleAt > now) && existing.staleAt > minStaleAt) {
|
|
483
|
+
// Restore from cache whether SSE is supported
|
|
484
|
+
if (existing.sse) supportsSSE.add(key);
|
|
485
|
+
|
|
486
|
+
// Reload features in the background if stale
|
|
487
|
+
if (existing.staleAt < now) {
|
|
488
|
+
fetchFeatures(instance);
|
|
489
|
+
}
|
|
490
|
+
// Otherwise, if we don't need to refresh now, start a background sync
|
|
491
|
+
else {
|
|
492
|
+
startAutoRefresh(instance);
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
data: existing.data,
|
|
496
|
+
success: true,
|
|
497
|
+
source: "cache"
|
|
498
|
+
};
|
|
499
|
+
} else {
|
|
500
|
+
const res = await promiseTimeout(fetchFeatures(instance), timeout);
|
|
501
|
+
return res || {
|
|
502
|
+
data: null,
|
|
503
|
+
success: false,
|
|
504
|
+
source: "timeout",
|
|
505
|
+
error: new Error("Timeout")
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function getKey(instance) {
|
|
510
|
+
const [apiHost, clientKey] = instance.getApiInfo();
|
|
511
|
+
return `${apiHost}||${clientKey}`;
|
|
512
|
+
}
|
|
513
|
+
function getCacheKey(instance) {
|
|
514
|
+
const baseKey = getKey(instance);
|
|
515
|
+
if (!("isRemoteEval" in instance) || !instance.isRemoteEval()) return baseKey;
|
|
516
|
+
const attributes = instance.getAttributes();
|
|
517
|
+
const cacheKeyAttributes = instance.getCacheKeyAttributes() || Object.keys(instance.getAttributes());
|
|
518
|
+
const ca = {};
|
|
519
|
+
cacheKeyAttributes.forEach(key => {
|
|
520
|
+
ca[key] = attributes[key];
|
|
521
|
+
});
|
|
522
|
+
const fv = instance.getForcedVariations();
|
|
523
|
+
const url = instance.getUrl();
|
|
524
|
+
return `${baseKey}||${JSON.stringify({
|
|
525
|
+
ca,
|
|
526
|
+
fv,
|
|
527
|
+
url
|
|
528
|
+
})}`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Populate cache from localStorage (if available)
|
|
532
|
+
async function initializeCache() {
|
|
533
|
+
if (cacheInitialized) return;
|
|
534
|
+
cacheInitialized = true;
|
|
535
|
+
try {
|
|
536
|
+
if (polyfills.localStorage) {
|
|
537
|
+
const value = await polyfills.localStorage.getItem(cacheSettings.cacheKey);
|
|
538
|
+
if (!cacheSettings.disableCache && value) {
|
|
539
|
+
const parsed = JSON.parse(value);
|
|
540
|
+
if (parsed && Array.isArray(parsed)) {
|
|
541
|
+
parsed.forEach(_ref6 => {
|
|
542
|
+
let [key, data] = _ref6;
|
|
543
|
+
cache.set(key, {
|
|
544
|
+
...data,
|
|
545
|
+
staleAt: new Date(data.staleAt)
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
cleanupCache();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
} catch (e) {
|
|
553
|
+
// Ignore localStorage errors
|
|
554
|
+
}
|
|
555
|
+
if (!cacheSettings.disableIdleStreams) {
|
|
556
|
+
const cleanupFn = helpers.startIdleListener();
|
|
557
|
+
if (cleanupFn) {
|
|
558
|
+
helpers.stopIdleListener = cleanupFn;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Enforce the maxEntries limit
|
|
564
|
+
function cleanupCache() {
|
|
565
|
+
const entriesWithTimestamps = Array.from(cache.entries()).map(_ref7 => {
|
|
566
|
+
let [key, value] = _ref7;
|
|
567
|
+
return {
|
|
568
|
+
key,
|
|
569
|
+
staleAt: value.staleAt.getTime()
|
|
570
|
+
};
|
|
571
|
+
}).sort((a, b) => a.staleAt - b.staleAt);
|
|
572
|
+
const entriesToRemoveCount = Math.min(Math.max(0, cache.size - cacheSettings.maxEntries), cache.size);
|
|
573
|
+
for (let i = 0; i < entriesToRemoveCount; i++) {
|
|
574
|
+
cache.delete(entriesWithTimestamps[i].key);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Called whenever new features are fetched from the API
|
|
579
|
+
function onNewFeatureData(key, cacheKey, data) {
|
|
580
|
+
// If contents haven't changed, ignore the update, extend the stale TTL
|
|
581
|
+
const version = data.dateUpdated || "";
|
|
582
|
+
const staleAt = new Date(Date.now() + cacheSettings.staleTTL);
|
|
583
|
+
const existing = !cacheSettings.disableCache ? cache.get(cacheKey) : undefined;
|
|
584
|
+
if (existing && version && existing.version === version) {
|
|
585
|
+
existing.staleAt = staleAt;
|
|
586
|
+
updatePersistentCache();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (!cacheSettings.disableCache) {
|
|
590
|
+
// Update in-memory cache
|
|
591
|
+
cache.set(cacheKey, {
|
|
592
|
+
data,
|
|
593
|
+
version,
|
|
594
|
+
staleAt,
|
|
595
|
+
sse: supportsSSE.has(key)
|
|
596
|
+
});
|
|
597
|
+
cleanupCache();
|
|
598
|
+
}
|
|
599
|
+
// Update local storage (don't await this, just update asynchronously)
|
|
600
|
+
updatePersistentCache();
|
|
601
|
+
|
|
602
|
+
// Update features for all subscribed GrowthBook instances
|
|
603
|
+
const instances = subscribedInstances.get(key);
|
|
604
|
+
instances && instances.forEach(instance => refreshInstance(instance, data));
|
|
605
|
+
}
|
|
606
|
+
async function refreshInstance(instance, data) {
|
|
607
|
+
await instance.setPayload(data || instance.getPayload());
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Fetch the features payload from helper function or from in-mem injected payload
|
|
611
|
+
async function fetchFeatures(instance) {
|
|
612
|
+
const {
|
|
613
|
+
apiHost,
|
|
614
|
+
apiRequestHeaders
|
|
615
|
+
} = instance.getApiHosts();
|
|
616
|
+
const clientKey = instance.getClientKey();
|
|
617
|
+
const remoteEval = "isRemoteEval" in instance && instance.isRemoteEval();
|
|
618
|
+
const key = getKey(instance);
|
|
619
|
+
const cacheKey = getCacheKey(instance);
|
|
620
|
+
let promise = activeFetches.get(cacheKey);
|
|
621
|
+
if (!promise) {
|
|
622
|
+
const fetcher = remoteEval ? helpers.fetchRemoteEvalCall({
|
|
623
|
+
host: apiHost,
|
|
624
|
+
clientKey,
|
|
625
|
+
payload: {
|
|
626
|
+
attributes: instance.getAttributes(),
|
|
627
|
+
forcedVariations: instance.getForcedVariations(),
|
|
628
|
+
forcedFeatures: Array.from(instance.getForcedFeatures().entries()),
|
|
629
|
+
url: instance.getUrl()
|
|
630
|
+
},
|
|
631
|
+
headers: apiRequestHeaders
|
|
632
|
+
}) : helpers.fetchFeaturesCall({
|
|
633
|
+
host: apiHost,
|
|
634
|
+
clientKey,
|
|
635
|
+
headers: apiRequestHeaders
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// TODO: auto-retry if status code indicates a temporary error
|
|
639
|
+
promise = fetcher.then(res => {
|
|
640
|
+
if (!res.ok) {
|
|
641
|
+
throw new Error(`HTTP error: ${res.status}`);
|
|
642
|
+
}
|
|
643
|
+
if (res.headers.get("x-sse-support") === "enabled") {
|
|
644
|
+
supportsSSE.add(key);
|
|
645
|
+
}
|
|
646
|
+
return res.json();
|
|
647
|
+
}).then(data => {
|
|
648
|
+
onNewFeatureData(key, cacheKey, data);
|
|
649
|
+
startAutoRefresh(instance);
|
|
650
|
+
activeFetches.delete(cacheKey);
|
|
651
|
+
return {
|
|
652
|
+
data,
|
|
653
|
+
success: true,
|
|
654
|
+
source: "network"
|
|
655
|
+
};
|
|
656
|
+
}).catch(e => {
|
|
657
|
+
activeFetches.delete(cacheKey);
|
|
658
|
+
return {
|
|
659
|
+
data: null,
|
|
660
|
+
source: "error",
|
|
661
|
+
success: false,
|
|
662
|
+
error: e
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
activeFetches.set(cacheKey, promise);
|
|
666
|
+
}
|
|
667
|
+
return promise;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Start SSE streaming, listens to feature payload changes and triggers a refresh or re-fetch
|
|
671
|
+
function startAutoRefresh(instance) {
|
|
672
|
+
let forceSSE = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
|
|
673
|
+
const key = getKey(instance);
|
|
674
|
+
const cacheKey = getCacheKey(instance);
|
|
675
|
+
const {
|
|
676
|
+
streamingHost,
|
|
677
|
+
streamingHostRequestHeaders
|
|
678
|
+
} = instance.getApiHosts();
|
|
679
|
+
const clientKey = instance.getClientKey();
|
|
680
|
+
if (forceSSE) {
|
|
681
|
+
supportsSSE.add(key);
|
|
682
|
+
}
|
|
683
|
+
if (cacheSettings.backgroundSync && supportsSSE.has(key) && polyfills.EventSource) {
|
|
684
|
+
if (streams.has(key)) return;
|
|
685
|
+
const channel = {
|
|
686
|
+
src: null,
|
|
687
|
+
host: streamingHost,
|
|
688
|
+
clientKey,
|
|
689
|
+
headers: streamingHostRequestHeaders,
|
|
690
|
+
cb: event => {
|
|
691
|
+
try {
|
|
692
|
+
if (event.type === "features-updated") {
|
|
693
|
+
const instances = subscribedInstances.get(key);
|
|
694
|
+
instances && instances.forEach(instance => {
|
|
695
|
+
fetchFeatures(instance);
|
|
696
|
+
});
|
|
697
|
+
} else if (event.type === "features") {
|
|
698
|
+
const json = JSON.parse(event.data);
|
|
699
|
+
onNewFeatureData(key, cacheKey, json);
|
|
700
|
+
}
|
|
701
|
+
// Reset error count on success
|
|
702
|
+
channel.errors = 0;
|
|
703
|
+
} catch (e) {
|
|
704
|
+
onSSEError(channel);
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
errors: 0,
|
|
708
|
+
state: "active"
|
|
709
|
+
};
|
|
710
|
+
streams.set(key, channel);
|
|
711
|
+
enableChannel(channel);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function onSSEError(channel) {
|
|
715
|
+
if (channel.state === "idle") return;
|
|
716
|
+
channel.errors++;
|
|
717
|
+
if (channel.errors > 3 || channel.src && channel.src.readyState === 2) {
|
|
718
|
+
// exponential backoff after 4 errors, with jitter
|
|
719
|
+
const delay = Math.pow(3, channel.errors - 3) * (1000 + Math.random() * 1000);
|
|
720
|
+
disableChannel(channel);
|
|
721
|
+
setTimeout(() => {
|
|
722
|
+
if (["idle", "active"].includes(channel.state)) return;
|
|
723
|
+
enableChannel(channel);
|
|
724
|
+
}, Math.min(delay, 300000)); // 5 minutes max
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function disableChannel(channel) {
|
|
729
|
+
if (!channel.src) return;
|
|
730
|
+
channel.src.onopen = null;
|
|
731
|
+
channel.src.onerror = null;
|
|
732
|
+
channel.src.close();
|
|
733
|
+
channel.src = null;
|
|
734
|
+
if (channel.state === "active") {
|
|
735
|
+
channel.state = "disabled";
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function enableChannel(channel) {
|
|
739
|
+
channel.src = helpers.eventSourceCall({
|
|
740
|
+
host: channel.host,
|
|
741
|
+
clientKey: channel.clientKey,
|
|
742
|
+
headers: channel.headers
|
|
743
|
+
});
|
|
744
|
+
channel.state = "active";
|
|
745
|
+
channel.src.addEventListener("features", channel.cb);
|
|
746
|
+
channel.src.addEventListener("features-updated", channel.cb);
|
|
747
|
+
channel.src.onerror = () => onSSEError(channel);
|
|
748
|
+
channel.src.onopen = () => {
|
|
749
|
+
channel.errors = 0;
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function destroyChannel(channel, key) {
|
|
753
|
+
disableChannel(channel);
|
|
754
|
+
streams.delete(key);
|
|
755
|
+
}
|
|
756
|
+
function clearAutoRefresh() {
|
|
757
|
+
// Clear list of which keys are auto-updated
|
|
758
|
+
supportsSSE.clear();
|
|
759
|
+
|
|
760
|
+
// Stop listening for any SSE events
|
|
761
|
+
streams.forEach(destroyChannel);
|
|
762
|
+
|
|
763
|
+
// Remove all references to GrowthBook instances
|
|
764
|
+
subscribedInstances.clear();
|
|
765
|
+
|
|
766
|
+
// Run the idle stream cleanup function
|
|
767
|
+
helpers.stopIdleListener();
|
|
768
|
+
}
|
|
769
|
+
function startStreaming(instance, options) {
|
|
770
|
+
if (options.streaming) {
|
|
771
|
+
if (!instance.getClientKey()) {
|
|
772
|
+
throw new Error("Must specify clientKey to enable streaming");
|
|
773
|
+
}
|
|
774
|
+
if (options.payload) {
|
|
775
|
+
startAutoRefresh(instance, true);
|
|
776
|
+
}
|
|
777
|
+
subscribe(instance);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
var validAttributeName = /^[a-zA-Z:_][a-zA-Z0-9:_.-]*$/;
|
|
782
|
+
var nullController = {
|
|
783
|
+
revert: function revert() {}
|
|
784
|
+
};
|
|
785
|
+
var elements = /*#__PURE__*/new Map();
|
|
786
|
+
var mutations = /*#__PURE__*/new Set();
|
|
787
|
+
function getObserverInit(attr) {
|
|
788
|
+
return attr === 'html' ? {
|
|
789
|
+
childList: true,
|
|
790
|
+
subtree: true,
|
|
791
|
+
attributes: true,
|
|
792
|
+
characterData: true
|
|
793
|
+
} : {
|
|
794
|
+
childList: false,
|
|
795
|
+
subtree: false,
|
|
796
|
+
attributes: true,
|
|
797
|
+
attributeFilter: [attr]
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
function getElementRecord(element) {
|
|
801
|
+
var record = elements.get(element);
|
|
802
|
+
if (!record) {
|
|
803
|
+
record = {
|
|
804
|
+
element: element,
|
|
805
|
+
attributes: {}
|
|
806
|
+
};
|
|
807
|
+
elements.set(element, record);
|
|
808
|
+
}
|
|
809
|
+
return record;
|
|
810
|
+
}
|
|
811
|
+
function createElementPropertyRecord(el, attr, getCurrentValue, setValue, mutationRunner) {
|
|
812
|
+
var currentValue = getCurrentValue(el);
|
|
813
|
+
var record = {
|
|
814
|
+
isDirty: false,
|
|
815
|
+
originalValue: currentValue,
|
|
816
|
+
virtualValue: currentValue,
|
|
817
|
+
mutations: [],
|
|
818
|
+
el: el,
|
|
819
|
+
_positionTimeout: null,
|
|
820
|
+
observer: new MutationObserver(function () {
|
|
821
|
+
// enact a 1 second timeout that blocks subsequent firing of the
|
|
822
|
+
// observer until the timeout is complete. This will prevent multiple
|
|
823
|
+
// mutations from firing in quick succession, which can cause the
|
|
824
|
+
// mutation to be reverted before the DOM has a chance to update.
|
|
825
|
+
if (attr === 'position' && record._positionTimeout) return;else if (attr === 'position') record._positionTimeout = setTimeout(function () {
|
|
826
|
+
record._positionTimeout = null;
|
|
827
|
+
}, 1000);
|
|
828
|
+
var currentValue = getCurrentValue(el);
|
|
829
|
+
if (attr === 'position' && currentValue.parentNode === record.virtualValue.parentNode && currentValue.insertBeforeNode === record.virtualValue.insertBeforeNode) return;
|
|
830
|
+
if (currentValue === record.virtualValue) return;
|
|
831
|
+
record.originalValue = currentValue;
|
|
832
|
+
mutationRunner(record);
|
|
833
|
+
}),
|
|
834
|
+
mutationRunner: mutationRunner,
|
|
835
|
+
setValue: setValue,
|
|
836
|
+
getCurrentValue: getCurrentValue
|
|
837
|
+
};
|
|
838
|
+
if (attr === 'position' && el.parentNode) {
|
|
839
|
+
record.observer.observe(el.parentNode, {
|
|
840
|
+
childList: true,
|
|
841
|
+
subtree: true,
|
|
842
|
+
attributes: false,
|
|
843
|
+
characterData: false
|
|
844
|
+
});
|
|
845
|
+
} else {
|
|
846
|
+
record.observer.observe(el, getObserverInit(attr));
|
|
847
|
+
}
|
|
848
|
+
return record;
|
|
849
|
+
}
|
|
850
|
+
function queueIfNeeded(val, record) {
|
|
851
|
+
var currentVal = record.getCurrentValue(record.el);
|
|
852
|
+
record.virtualValue = val;
|
|
853
|
+
if (val && typeof val !== 'string') {
|
|
854
|
+
if (!currentVal || val.parentNode !== currentVal.parentNode || val.insertBeforeNode !== currentVal.insertBeforeNode) {
|
|
855
|
+
record.isDirty = true;
|
|
856
|
+
runDOMUpdates();
|
|
857
|
+
}
|
|
858
|
+
} else if (val !== currentVal) {
|
|
859
|
+
record.isDirty = true;
|
|
860
|
+
runDOMUpdates();
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
function htmlMutationRunner(record) {
|
|
864
|
+
var val = record.originalValue;
|
|
865
|
+
record.mutations.forEach(function (m) {
|
|
866
|
+
return val = m.mutate(val);
|
|
867
|
+
});
|
|
868
|
+
queueIfNeeded(getTransformedHTML(val), record);
|
|
869
|
+
}
|
|
870
|
+
function classMutationRunner(record) {
|
|
871
|
+
var val = new Set(record.originalValue.split(/\s+/).filter(Boolean));
|
|
872
|
+
record.mutations.forEach(function (m) {
|
|
873
|
+
return m.mutate(val);
|
|
874
|
+
});
|
|
875
|
+
queueIfNeeded(Array.from(val).filter(Boolean).join(' '), record);
|
|
876
|
+
}
|
|
877
|
+
function attrMutationRunner(record) {
|
|
878
|
+
var val = record.originalValue;
|
|
879
|
+
record.mutations.forEach(function (m) {
|
|
880
|
+
return val = m.mutate(val);
|
|
881
|
+
});
|
|
882
|
+
queueIfNeeded(val, record);
|
|
883
|
+
}
|
|
884
|
+
function _loadDOMNodes(_ref) {
|
|
885
|
+
var parentSelector = _ref.parentSelector,
|
|
886
|
+
insertBeforeSelector = _ref.insertBeforeSelector;
|
|
887
|
+
var parentNode = document.querySelector(parentSelector);
|
|
888
|
+
if (!parentNode) return null;
|
|
889
|
+
var insertBeforeNode = insertBeforeSelector ? document.querySelector(insertBeforeSelector) : null;
|
|
890
|
+
if (insertBeforeSelector && !insertBeforeNode) return null;
|
|
891
|
+
return {
|
|
892
|
+
parentNode: parentNode,
|
|
893
|
+
insertBeforeNode: insertBeforeNode
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
function positionMutationRunner(record) {
|
|
897
|
+
var val = record.originalValue;
|
|
898
|
+
record.mutations.forEach(function (m) {
|
|
899
|
+
var selectors = m.mutate();
|
|
900
|
+
var newNodes = _loadDOMNodes(selectors);
|
|
901
|
+
val = newNodes || val;
|
|
902
|
+
});
|
|
903
|
+
queueIfNeeded(val, record);
|
|
904
|
+
}
|
|
905
|
+
var getHTMLValue = function getHTMLValue(el) {
|
|
906
|
+
return el.innerHTML;
|
|
907
|
+
};
|
|
908
|
+
var setHTMLValue = function setHTMLValue(el, value) {
|
|
909
|
+
return el.innerHTML = value;
|
|
910
|
+
};
|
|
911
|
+
function getElementHTMLRecord(element) {
|
|
912
|
+
var elementRecord = getElementRecord(element);
|
|
913
|
+
if (!elementRecord.html) {
|
|
914
|
+
elementRecord.html = createElementPropertyRecord(element, 'html', getHTMLValue, setHTMLValue, htmlMutationRunner);
|
|
915
|
+
}
|
|
916
|
+
return elementRecord.html;
|
|
917
|
+
}
|
|
918
|
+
var getElementPosition = function getElementPosition(el) {
|
|
919
|
+
return {
|
|
920
|
+
parentNode: el.parentElement,
|
|
921
|
+
insertBeforeNode: el.nextElementSibling
|
|
922
|
+
};
|
|
923
|
+
};
|
|
924
|
+
var setElementPosition = function setElementPosition(el, value) {
|
|
925
|
+
if (value.insertBeforeNode && !value.parentNode.contains(value.insertBeforeNode)) {
|
|
926
|
+
// skip position mutation - insertBeforeNode not a child of parent. happens
|
|
927
|
+
// when mutation observer for indvidual element fires out of order
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
value.parentNode.insertBefore(el, value.insertBeforeNode);
|
|
931
|
+
};
|
|
932
|
+
function getElementPositionRecord(element) {
|
|
933
|
+
var elementRecord = getElementRecord(element);
|
|
934
|
+
if (!elementRecord.position) {
|
|
935
|
+
elementRecord.position = createElementPropertyRecord(element, 'position', getElementPosition, setElementPosition, positionMutationRunner);
|
|
936
|
+
}
|
|
937
|
+
return elementRecord.position;
|
|
938
|
+
}
|
|
939
|
+
var setClassValue = function setClassValue(el, val) {
|
|
940
|
+
return val ? el.className = val : el.removeAttribute('class');
|
|
941
|
+
};
|
|
942
|
+
var getClassValue = function getClassValue(el) {
|
|
943
|
+
return el.className;
|
|
944
|
+
};
|
|
945
|
+
function getElementClassRecord(el) {
|
|
946
|
+
var elementRecord = getElementRecord(el);
|
|
947
|
+
if (!elementRecord.classes) {
|
|
948
|
+
elementRecord.classes = createElementPropertyRecord(el, 'class', getClassValue, setClassValue, classMutationRunner);
|
|
949
|
+
}
|
|
950
|
+
return elementRecord.classes;
|
|
951
|
+
}
|
|
952
|
+
var getAttrValue = function getAttrValue(attrName) {
|
|
953
|
+
return function (el) {
|
|
954
|
+
var _el$getAttribute;
|
|
955
|
+
return (_el$getAttribute = el.getAttribute(attrName)) != null ? _el$getAttribute : null;
|
|
956
|
+
};
|
|
957
|
+
};
|
|
958
|
+
var setAttrValue = function setAttrValue(attrName) {
|
|
959
|
+
return function (el, val) {
|
|
960
|
+
return val !== null ? el.setAttribute(attrName, val) : el.removeAttribute(attrName);
|
|
961
|
+
};
|
|
962
|
+
};
|
|
963
|
+
function getElementAttributeRecord(el, attr) {
|
|
964
|
+
var elementRecord = getElementRecord(el);
|
|
965
|
+
if (!elementRecord.attributes[attr]) {
|
|
966
|
+
elementRecord.attributes[attr] = createElementPropertyRecord(el, attr, getAttrValue(attr), setAttrValue(attr), attrMutationRunner);
|
|
967
|
+
}
|
|
968
|
+
return elementRecord.attributes[attr];
|
|
969
|
+
}
|
|
970
|
+
function deleteElementPropertyRecord(el, attr) {
|
|
971
|
+
var element = elements.get(el);
|
|
972
|
+
if (!element) return;
|
|
973
|
+
if (attr === 'html') {
|
|
974
|
+
var _element$html, _element$html$observe;
|
|
975
|
+
(_element$html = element.html) == null ? void 0 : (_element$html$observe = _element$html.observer) == null ? void 0 : _element$html$observe.disconnect();
|
|
976
|
+
delete element.html;
|
|
977
|
+
} else if (attr === 'class') {
|
|
978
|
+
var _element$classes, _element$classes$obse;
|
|
979
|
+
(_element$classes = element.classes) == null ? void 0 : (_element$classes$obse = _element$classes.observer) == null ? void 0 : _element$classes$obse.disconnect();
|
|
980
|
+
delete element.classes;
|
|
981
|
+
} else if (attr === 'position') {
|
|
982
|
+
var _element$position, _element$position$obs;
|
|
983
|
+
(_element$position = element.position) == null ? void 0 : (_element$position$obs = _element$position.observer) == null ? void 0 : _element$position$obs.disconnect();
|
|
984
|
+
delete element.position;
|
|
985
|
+
} else {
|
|
986
|
+
var _element$attributes, _element$attributes$a, _element$attributes$a2;
|
|
987
|
+
(_element$attributes = element.attributes) == null ? void 0 : (_element$attributes$a = _element$attributes[attr]) == null ? void 0 : (_element$attributes$a2 = _element$attributes$a.observer) == null ? void 0 : _element$attributes$a2.disconnect();
|
|
988
|
+
delete element.attributes[attr];
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
var transformContainer;
|
|
992
|
+
function getTransformedHTML(html) {
|
|
993
|
+
if (!transformContainer) {
|
|
994
|
+
transformContainer = document.createElement('div');
|
|
995
|
+
}
|
|
996
|
+
transformContainer.innerHTML = html;
|
|
997
|
+
return transformContainer.innerHTML;
|
|
998
|
+
}
|
|
999
|
+
function setPropertyValue(el, attr, m) {
|
|
1000
|
+
if (!m.isDirty) return;
|
|
1001
|
+
m.isDirty = false;
|
|
1002
|
+
var val = m.virtualValue;
|
|
1003
|
+
if (!m.mutations.length) {
|
|
1004
|
+
deleteElementPropertyRecord(el, attr);
|
|
1005
|
+
}
|
|
1006
|
+
m.setValue(el, val);
|
|
1007
|
+
}
|
|
1008
|
+
function setValue(m, el) {
|
|
1009
|
+
m.html && setPropertyValue(el, 'html', m.html);
|
|
1010
|
+
m.classes && setPropertyValue(el, 'class', m.classes);
|
|
1011
|
+
m.position && setPropertyValue(el, 'position', m.position);
|
|
1012
|
+
Object.keys(m.attributes).forEach(function (attr) {
|
|
1013
|
+
setPropertyValue(el, attr, m.attributes[attr]);
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
function runDOMUpdates() {
|
|
1017
|
+
elements.forEach(setValue);
|
|
1018
|
+
} // find or create ElementPropertyRecord, add mutation to it, then run
|
|
1019
|
+
|
|
1020
|
+
function startMutating(mutation, element) {
|
|
1021
|
+
var record = null;
|
|
1022
|
+
if (mutation.kind === 'html') {
|
|
1023
|
+
record = getElementHTMLRecord(element);
|
|
1024
|
+
} else if (mutation.kind === 'class') {
|
|
1025
|
+
record = getElementClassRecord(element);
|
|
1026
|
+
} else if (mutation.kind === 'attribute') {
|
|
1027
|
+
record = getElementAttributeRecord(element, mutation.attribute);
|
|
1028
|
+
} else if (mutation.kind === 'position') {
|
|
1029
|
+
record = getElementPositionRecord(element);
|
|
1030
|
+
}
|
|
1031
|
+
if (!record) return;
|
|
1032
|
+
record.mutations.push(mutation);
|
|
1033
|
+
record.mutationRunner(record);
|
|
1034
|
+
} // get (existing) ElementPropertyRecord, remove mutation from it, then run
|
|
1035
|
+
|
|
1036
|
+
function stopMutating(mutation, el) {
|
|
1037
|
+
var record = null;
|
|
1038
|
+
if (mutation.kind === 'html') {
|
|
1039
|
+
record = getElementHTMLRecord(el);
|
|
1040
|
+
} else if (mutation.kind === 'class') {
|
|
1041
|
+
record = getElementClassRecord(el);
|
|
1042
|
+
} else if (mutation.kind === 'attribute') {
|
|
1043
|
+
record = getElementAttributeRecord(el, mutation.attribute);
|
|
1044
|
+
} else if (mutation.kind === 'position') {
|
|
1045
|
+
record = getElementPositionRecord(el);
|
|
1046
|
+
}
|
|
1047
|
+
if (!record) return;
|
|
1048
|
+
var index = record.mutations.indexOf(mutation);
|
|
1049
|
+
if (index !== -1) record.mutations.splice(index, 1);
|
|
1050
|
+
record.mutationRunner(record);
|
|
1051
|
+
} // maintain list of elements associated with mutation
|
|
1052
|
+
|
|
1053
|
+
function refreshElementsSet(mutation) {
|
|
1054
|
+
// if a position mutation has already found an element to move, don't move
|
|
1055
|
+
// any more elements
|
|
1056
|
+
if (mutation.kind === 'position' && mutation.elements.size === 1) return;
|
|
1057
|
+
var existingElements = new Set(mutation.elements);
|
|
1058
|
+
var matchingElements = document.querySelectorAll(mutation.selector);
|
|
1059
|
+
matchingElements.forEach(function (el) {
|
|
1060
|
+
if (!existingElements.has(el)) {
|
|
1061
|
+
mutation.elements.add(el);
|
|
1062
|
+
startMutating(mutation, el);
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
function revertMutation(mutation) {
|
|
1067
|
+
mutation.elements.forEach(function (el) {
|
|
1068
|
+
return stopMutating(mutation, el);
|
|
1069
|
+
});
|
|
1070
|
+
mutation.elements.clear();
|
|
1071
|
+
mutations["delete"](mutation);
|
|
1072
|
+
}
|
|
1073
|
+
function refreshAllElementSets() {
|
|
1074
|
+
mutations.forEach(refreshElementsSet);
|
|
1075
|
+
} // Observer for elements that don't exist in the DOM yet
|
|
1076
|
+
|
|
1077
|
+
var observer;
|
|
1078
|
+
function connectGlobalObserver() {
|
|
1079
|
+
if (typeof document === 'undefined') return;
|
|
1080
|
+
if (!observer) {
|
|
1081
|
+
observer = new MutationObserver(function () {
|
|
1082
|
+
refreshAllElementSets();
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
refreshAllElementSets();
|
|
1086
|
+
observer.observe(document.documentElement, {
|
|
1087
|
+
childList: true,
|
|
1088
|
+
subtree: true,
|
|
1089
|
+
attributes: false,
|
|
1090
|
+
characterData: false
|
|
1091
|
+
});
|
|
1092
|
+
} // run on init
|
|
1093
|
+
|
|
1094
|
+
connectGlobalObserver();
|
|
1095
|
+
function newMutation(m) {
|
|
1096
|
+
// Not in a browser
|
|
1097
|
+
if (typeof document === 'undefined') return nullController; // add to global index of mutations
|
|
1098
|
+
|
|
1099
|
+
mutations.add(m); // run refresh on init to establish list of elements associated w/ mutation
|
|
1100
|
+
|
|
1101
|
+
refreshElementsSet(m);
|
|
1102
|
+
return {
|
|
1103
|
+
revert: function revert() {
|
|
1104
|
+
revertMutation(m);
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function html(selector, mutate) {
|
|
1109
|
+
return newMutation({
|
|
1110
|
+
kind: 'html',
|
|
1111
|
+
elements: new Set(),
|
|
1112
|
+
mutate: mutate,
|
|
1113
|
+
selector: selector
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
function position(selector, mutate) {
|
|
1117
|
+
return newMutation({
|
|
1118
|
+
kind: 'position',
|
|
1119
|
+
elements: new Set(),
|
|
1120
|
+
mutate: mutate,
|
|
1121
|
+
selector: selector
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
function classes(selector, mutate) {
|
|
1125
|
+
return newMutation({
|
|
1126
|
+
kind: 'class',
|
|
1127
|
+
elements: new Set(),
|
|
1128
|
+
mutate: mutate,
|
|
1129
|
+
selector: selector
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
function attribute(selector, attribute, mutate) {
|
|
1133
|
+
if (!validAttributeName.test(attribute)) return nullController;
|
|
1134
|
+
if (attribute === 'class' || attribute === 'className') {
|
|
1135
|
+
return classes(selector, function (classnames) {
|
|
1136
|
+
var mutatedClassnames = mutate(Array.from(classnames).join(' '));
|
|
1137
|
+
classnames.clear();
|
|
1138
|
+
if (!mutatedClassnames) return;
|
|
1139
|
+
mutatedClassnames.split(/\s+/g).filter(Boolean).forEach(function (c) {
|
|
1140
|
+
return classnames.add(c);
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
return newMutation({
|
|
1145
|
+
kind: 'attribute',
|
|
1146
|
+
attribute: attribute,
|
|
1147
|
+
elements: new Set(),
|
|
1148
|
+
mutate: mutate,
|
|
1149
|
+
selector: selector
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
function declarative(_ref2) {
|
|
1153
|
+
var selector = _ref2.selector,
|
|
1154
|
+
action = _ref2.action,
|
|
1155
|
+
value = _ref2.value,
|
|
1156
|
+
attr = _ref2.attribute,
|
|
1157
|
+
parentSelector = _ref2.parentSelector,
|
|
1158
|
+
insertBeforeSelector = _ref2.insertBeforeSelector;
|
|
1159
|
+
if (attr === 'html') {
|
|
1160
|
+
if (action === 'append') {
|
|
1161
|
+
return html(selector, function (val) {
|
|
1162
|
+
return val + (value != null ? value : '');
|
|
1163
|
+
});
|
|
1164
|
+
} else if (action === 'set') {
|
|
1165
|
+
return html(selector, function () {
|
|
1166
|
+
return value != null ? value : '';
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
} else if (attr === 'class') {
|
|
1170
|
+
if (action === 'append') {
|
|
1171
|
+
return classes(selector, function (val) {
|
|
1172
|
+
if (value) val.add(value);
|
|
1173
|
+
});
|
|
1174
|
+
} else if (action === 'remove') {
|
|
1175
|
+
return classes(selector, function (val) {
|
|
1176
|
+
if (value) val["delete"](value);
|
|
1177
|
+
});
|
|
1178
|
+
} else if (action === 'set') {
|
|
1179
|
+
return classes(selector, function (val) {
|
|
1180
|
+
val.clear();
|
|
1181
|
+
if (value) val.add(value);
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
} else if (attr === 'position') {
|
|
1185
|
+
if (action === 'set' && parentSelector) {
|
|
1186
|
+
return position(selector, function () {
|
|
1187
|
+
return {
|
|
1188
|
+
insertBeforeSelector: insertBeforeSelector,
|
|
1189
|
+
parentSelector: parentSelector
|
|
1190
|
+
};
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
} else {
|
|
1194
|
+
if (action === 'append') {
|
|
1195
|
+
return attribute(selector, attr, function (val) {
|
|
1196
|
+
return val !== null ? val + (value != null ? value : '') : value != null ? value : '';
|
|
1197
|
+
});
|
|
1198
|
+
} else if (action === 'set') {
|
|
1199
|
+
return attribute(selector, attr, function () {
|
|
1200
|
+
return value != null ? value : '';
|
|
1201
|
+
});
|
|
1202
|
+
} else if (action === 'remove') {
|
|
1203
|
+
return attribute(selector, attr, function () {
|
|
1204
|
+
return null;
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return nullController;
|
|
1209
|
+
}
|
|
1210
|
+
var index = {
|
|
1211
|
+
html: html,
|
|
1212
|
+
classes: classes,
|
|
1213
|
+
attribute: attribute,
|
|
1214
|
+
position: position,
|
|
1215
|
+
declarative: declarative
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
1219
|
+
const _regexCache = {};
|
|
1220
|
+
|
|
1221
|
+
// The top-level condition evaluation function
|
|
1222
|
+
function evalCondition(obj, condition,
|
|
1223
|
+
// Must be included for `condition` to correctly evaluate group Operators
|
|
1224
|
+
savedGroups) {
|
|
1225
|
+
savedGroups = savedGroups || {};
|
|
1226
|
+
// Condition is an object, keys are either specific operators or object paths
|
|
1227
|
+
// values are either arguments for operators or conditions for paths
|
|
1228
|
+
for (const [k, v] of Object.entries(condition)) {
|
|
1229
|
+
switch (k) {
|
|
1230
|
+
case "$or":
|
|
1231
|
+
if (!evalOr(obj, v, savedGroups)) return false;
|
|
1232
|
+
break;
|
|
1233
|
+
case "$nor":
|
|
1234
|
+
if (evalOr(obj, v, savedGroups)) return false;
|
|
1235
|
+
break;
|
|
1236
|
+
case "$and":
|
|
1237
|
+
if (!evalAnd(obj, v, savedGroups)) return false;
|
|
1238
|
+
break;
|
|
1239
|
+
case "$not":
|
|
1240
|
+
if (evalCondition(obj, v, savedGroups)) return false;
|
|
1241
|
+
break;
|
|
1242
|
+
default:
|
|
1243
|
+
if (!evalConditionValue(v, getPath(obj, k), savedGroups)) return false;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Return value at dot-separated path of an object
|
|
1250
|
+
function getPath(obj, path) {
|
|
1251
|
+
const parts = path.split(".");
|
|
1252
|
+
let current = obj;
|
|
1253
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1254
|
+
if (current && typeof current === "object" && parts[i] in current) {
|
|
1255
|
+
current = current[parts[i]];
|
|
1256
|
+
} else {
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return current;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// Transform a regex string into a real RegExp object
|
|
1264
|
+
function getRegex(regex) {
|
|
1265
|
+
if (!_regexCache[regex]) {
|
|
1266
|
+
_regexCache[regex] = new RegExp(regex.replace(/([^\\])\//g, "$1\\/"));
|
|
1267
|
+
}
|
|
1268
|
+
return _regexCache[regex];
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Evaluate a single value against a condition
|
|
1272
|
+
function evalConditionValue(condition, value, savedGroups) {
|
|
1273
|
+
// Simple equality comparisons
|
|
1274
|
+
if (typeof condition === "string") {
|
|
1275
|
+
return value + "" === condition;
|
|
1276
|
+
}
|
|
1277
|
+
if (typeof condition === "number") {
|
|
1278
|
+
return value * 1 === condition;
|
|
1279
|
+
}
|
|
1280
|
+
if (typeof condition === "boolean") {
|
|
1281
|
+
return value !== null && !!value === condition;
|
|
1282
|
+
}
|
|
1283
|
+
if (condition === null) {
|
|
1284
|
+
return value === null;
|
|
1285
|
+
}
|
|
1286
|
+
if (Array.isArray(condition) || !isOperatorObject(condition)) {
|
|
1287
|
+
return JSON.stringify(value) === JSON.stringify(condition);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// This is a special operator condition and we should evaluate each one separately
|
|
1291
|
+
for (const op in condition) {
|
|
1292
|
+
if (!evalOperatorCondition(op, value, condition[op], savedGroups)) {
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return true;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// If the object has only keys that start with '$'
|
|
1300
|
+
function isOperatorObject(obj) {
|
|
1301
|
+
const keys = Object.keys(obj);
|
|
1302
|
+
return keys.length > 0 && keys.filter(k => k[0] === "$").length === keys.length;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Return the data type of a value
|
|
1306
|
+
function getType(v) {
|
|
1307
|
+
if (v === null) return "null";
|
|
1308
|
+
if (Array.isArray(v)) return "array";
|
|
1309
|
+
const t = typeof v;
|
|
1310
|
+
if (["string", "number", "boolean", "object", "undefined"].includes(t)) {
|
|
1311
|
+
return t;
|
|
1312
|
+
}
|
|
1313
|
+
return "unknown";
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// At least one element of actual must match the expected condition/value
|
|
1317
|
+
function elemMatch(actual, expected, savedGroups) {
|
|
1318
|
+
if (!Array.isArray(actual)) return false;
|
|
1319
|
+
const check = isOperatorObject(expected) ? v => evalConditionValue(expected, v, savedGroups) : v => evalCondition(v, expected, savedGroups);
|
|
1320
|
+
for (let i = 0; i < actual.length; i++) {
|
|
1321
|
+
if (actual[i] && check(actual[i])) {
|
|
1322
|
+
return true;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
function isIn(actual, expected) {
|
|
1328
|
+
// Do an intersection if attribute is an array
|
|
1329
|
+
if (Array.isArray(actual)) {
|
|
1330
|
+
return actual.some(el => expected.includes(el));
|
|
1331
|
+
}
|
|
1332
|
+
return expected.includes(actual);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Evaluate a single operator condition
|
|
1336
|
+
function evalOperatorCondition(operator, actual, expected, savedGroups) {
|
|
1337
|
+
switch (operator) {
|
|
1338
|
+
case "$veq":
|
|
1339
|
+
return paddedVersionString(actual) === paddedVersionString(expected);
|
|
1340
|
+
case "$vne":
|
|
1341
|
+
return paddedVersionString(actual) !== paddedVersionString(expected);
|
|
1342
|
+
case "$vgt":
|
|
1343
|
+
return paddedVersionString(actual) > paddedVersionString(expected);
|
|
1344
|
+
case "$vgte":
|
|
1345
|
+
return paddedVersionString(actual) >= paddedVersionString(expected);
|
|
1346
|
+
case "$vlt":
|
|
1347
|
+
return paddedVersionString(actual) < paddedVersionString(expected);
|
|
1348
|
+
case "$vlte":
|
|
1349
|
+
return paddedVersionString(actual) <= paddedVersionString(expected);
|
|
1350
|
+
case "$eq":
|
|
1351
|
+
return actual === expected;
|
|
1352
|
+
case "$ne":
|
|
1353
|
+
return actual !== expected;
|
|
1354
|
+
case "$lt":
|
|
1355
|
+
return actual < expected;
|
|
1356
|
+
case "$lte":
|
|
1357
|
+
return actual <= expected;
|
|
1358
|
+
case "$gt":
|
|
1359
|
+
return actual > expected;
|
|
1360
|
+
case "$gte":
|
|
1361
|
+
return actual >= expected;
|
|
1362
|
+
case "$exists":
|
|
1363
|
+
// Using `!=` and `==` instead of strict checks so it also matches for undefined
|
|
1364
|
+
return expected ? actual != null : actual == null;
|
|
1365
|
+
case "$in":
|
|
1366
|
+
if (!Array.isArray(expected)) return false;
|
|
1367
|
+
return isIn(actual, expected);
|
|
1368
|
+
case "$inGroup":
|
|
1369
|
+
return isIn(actual, savedGroups[expected] || []);
|
|
1370
|
+
case "$notInGroup":
|
|
1371
|
+
return !isIn(actual, savedGroups[expected] || []);
|
|
1372
|
+
case "$nin":
|
|
1373
|
+
if (!Array.isArray(expected)) return false;
|
|
1374
|
+
return !isIn(actual, expected);
|
|
1375
|
+
case "$not":
|
|
1376
|
+
return !evalConditionValue(expected, actual, savedGroups);
|
|
1377
|
+
case "$size":
|
|
1378
|
+
if (!Array.isArray(actual)) return false;
|
|
1379
|
+
return evalConditionValue(expected, actual.length, savedGroups);
|
|
1380
|
+
case "$elemMatch":
|
|
1381
|
+
return elemMatch(actual, expected, savedGroups);
|
|
1382
|
+
case "$all":
|
|
1383
|
+
if (!Array.isArray(actual)) return false;
|
|
1384
|
+
for (let i = 0; i < expected.length; i++) {
|
|
1385
|
+
let passed = false;
|
|
1386
|
+
for (let j = 0; j < actual.length; j++) {
|
|
1387
|
+
if (evalConditionValue(expected[i], actual[j], savedGroups)) {
|
|
1388
|
+
passed = true;
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
if (!passed) return false;
|
|
1393
|
+
}
|
|
1394
|
+
return true;
|
|
1395
|
+
case "$regex":
|
|
1396
|
+
try {
|
|
1397
|
+
return getRegex(expected).test(actual);
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
case "$type":
|
|
1402
|
+
return getType(actual) === expected;
|
|
1403
|
+
default:
|
|
1404
|
+
console.error("Unknown operator: " + operator);
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Recursive $or rule
|
|
1410
|
+
function evalOr(obj, conditions, savedGroups) {
|
|
1411
|
+
if (!conditions.length) return true;
|
|
1412
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
1413
|
+
if (evalCondition(obj, conditions[i], savedGroups)) {
|
|
1414
|
+
return true;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
return false;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Recursive $and rule
|
|
1421
|
+
function evalAnd(obj, conditions, savedGroups) {
|
|
1422
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
1423
|
+
if (!evalCondition(obj, conditions[i], savedGroups)) {
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
return true;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const EVENT_FEATURE_EVALUATED = "Feature Evaluated";
|
|
1431
|
+
const EVENT_EXPERIMENT_VIEWED = "Experiment Viewed";
|
|
1432
|
+
function getForcedFeatureValues(ctx) {
|
|
1433
|
+
// Merge user and global values
|
|
1434
|
+
const ret = new Map();
|
|
1435
|
+
if (ctx.global.forcedFeatureValues) {
|
|
1436
|
+
ctx.global.forcedFeatureValues.forEach((v, k) => ret.set(k, v));
|
|
1437
|
+
}
|
|
1438
|
+
if (ctx.user.forcedFeatureValues) {
|
|
1439
|
+
ctx.user.forcedFeatureValues.forEach((v, k) => ret.set(k, v));
|
|
1440
|
+
}
|
|
1441
|
+
return ret;
|
|
1442
|
+
}
|
|
1443
|
+
function getForcedVariations(ctx) {
|
|
1444
|
+
// Merge user and global values
|
|
1445
|
+
if (ctx.global.forcedVariations && ctx.user.forcedVariations) {
|
|
1446
|
+
return {
|
|
1447
|
+
...ctx.global.forcedVariations,
|
|
1448
|
+
...ctx.user.forcedVariations
|
|
1449
|
+
};
|
|
1450
|
+
} else if (ctx.global.forcedVariations) {
|
|
1451
|
+
return ctx.global.forcedVariations;
|
|
1452
|
+
} else if (ctx.user.forcedVariations) {
|
|
1453
|
+
return ctx.user.forcedVariations;
|
|
1454
|
+
} else {
|
|
1455
|
+
return {};
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
async function safeCall(fn) {
|
|
1459
|
+
try {
|
|
1460
|
+
await fn();
|
|
1461
|
+
} catch (e) {
|
|
1462
|
+
// Do nothing
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
function onExperimentViewed(ctx, experiment, result) {
|
|
1466
|
+
// Make sure a tracking callback is only fired once per unique experiment
|
|
1467
|
+
if (ctx.user.trackedExperiments) {
|
|
1468
|
+
const k = getExperimentDedupeKey(experiment, result);
|
|
1469
|
+
if (ctx.user.trackedExperiments.has(k)) {
|
|
1470
|
+
return [];
|
|
1471
|
+
}
|
|
1472
|
+
ctx.user.trackedExperiments.add(k);
|
|
1473
|
+
}
|
|
1474
|
+
if (ctx.user.enableDevMode && ctx.user.devLogs) {
|
|
1475
|
+
ctx.user.devLogs.push({
|
|
1476
|
+
experiment,
|
|
1477
|
+
result,
|
|
1478
|
+
timestamp: Date.now().toString(),
|
|
1479
|
+
logType: "experiment"
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
const calls = [];
|
|
1483
|
+
if (ctx.global.trackingCallback) {
|
|
1484
|
+
const cb = ctx.global.trackingCallback;
|
|
1485
|
+
calls.push(safeCall(() => cb(experiment, result, ctx.user)));
|
|
1486
|
+
}
|
|
1487
|
+
if (ctx.user.trackingCallback) {
|
|
1488
|
+
const cb = ctx.user.trackingCallback;
|
|
1489
|
+
calls.push(safeCall(() => cb(experiment, result)));
|
|
1490
|
+
}
|
|
1491
|
+
if (ctx.global.eventLogger) {
|
|
1492
|
+
const cb = ctx.global.eventLogger;
|
|
1493
|
+
calls.push(safeCall(() => cb(EVENT_EXPERIMENT_VIEWED, {
|
|
1494
|
+
experimentId: experiment.key,
|
|
1495
|
+
variationId: result.key,
|
|
1496
|
+
hashAttribute: result.hashAttribute,
|
|
1497
|
+
hashValue: result.hashValue
|
|
1498
|
+
}, ctx.user)));
|
|
1499
|
+
}
|
|
1500
|
+
return calls;
|
|
1501
|
+
}
|
|
1502
|
+
function onFeatureUsage(ctx, key, ret) {
|
|
1503
|
+
// Only track a feature once, unless the assigned value changed
|
|
1504
|
+
if (ctx.user.trackedFeatureUsage) {
|
|
1505
|
+
const stringifiedValue = JSON.stringify(ret.value);
|
|
1506
|
+
if (ctx.user.trackedFeatureUsage[key] === stringifiedValue) return;
|
|
1507
|
+
ctx.user.trackedFeatureUsage[key] = stringifiedValue;
|
|
1508
|
+
if (ctx.user.enableDevMode && ctx.user.devLogs) {
|
|
1509
|
+
ctx.user.devLogs.push({
|
|
1510
|
+
featureKey: key,
|
|
1511
|
+
result: ret,
|
|
1512
|
+
timestamp: Date.now().toString(),
|
|
1513
|
+
logType: "feature"
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if (ctx.global.onFeatureUsage) {
|
|
1518
|
+
const cb = ctx.global.onFeatureUsage;
|
|
1519
|
+
safeCall(() => cb(key, ret, ctx.user));
|
|
1520
|
+
}
|
|
1521
|
+
if (ctx.user.onFeatureUsage) {
|
|
1522
|
+
const cb = ctx.user.onFeatureUsage;
|
|
1523
|
+
safeCall(() => cb(key, ret));
|
|
1524
|
+
}
|
|
1525
|
+
if (ctx.global.eventLogger) {
|
|
1526
|
+
const cb = ctx.global.eventLogger;
|
|
1527
|
+
safeCall(() => cb(EVENT_FEATURE_EVALUATED, {
|
|
1528
|
+
feature: key,
|
|
1529
|
+
source: ret.source,
|
|
1530
|
+
value: ret.value,
|
|
1531
|
+
ruleId: ret.source === "defaultValue" ? "$default" : ret.ruleId || "",
|
|
1532
|
+
variationId: ret.experimentResult ? ret.experimentResult.key : ""
|
|
1533
|
+
}, ctx.user));
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
function evalFeature(id, ctx) {
|
|
1537
|
+
if (ctx.stack.evaluatedFeatures.has(id)) {
|
|
1538
|
+
return getFeatureResult(ctx, id, null, "cyclicPrerequisite");
|
|
1539
|
+
}
|
|
1540
|
+
ctx.stack.evaluatedFeatures.add(id);
|
|
1541
|
+
ctx.stack.id = id;
|
|
1542
|
+
|
|
1543
|
+
// Global override
|
|
1544
|
+
const forcedValues = getForcedFeatureValues(ctx);
|
|
1545
|
+
if (forcedValues.has(id)) {
|
|
1546
|
+
return getFeatureResult(ctx, id, forcedValues.get(id), "override");
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Unknown feature id
|
|
1550
|
+
if (!ctx.global.features || !ctx.global.features[id]) {
|
|
1551
|
+
return getFeatureResult(ctx, id, null, "unknownFeature");
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// Get the feature
|
|
1555
|
+
const feature = ctx.global.features[id];
|
|
1556
|
+
|
|
1557
|
+
// Loop through the rules
|
|
1558
|
+
if (feature.rules) {
|
|
1559
|
+
const evaluatedFeatures = new Set(ctx.stack.evaluatedFeatures);
|
|
1560
|
+
rules: for (const rule of feature.rules) {
|
|
1561
|
+
// If there are prerequisite flag(s), evaluate them
|
|
1562
|
+
if (rule.parentConditions) {
|
|
1563
|
+
for (const parentCondition of rule.parentConditions) {
|
|
1564
|
+
ctx.stack.evaluatedFeatures = new Set(evaluatedFeatures);
|
|
1565
|
+
const parentResult = evalFeature(parentCondition.id, ctx);
|
|
1566
|
+
// break out for cyclic prerequisites
|
|
1567
|
+
if (parentResult.source === "cyclicPrerequisite") {
|
|
1568
|
+
return getFeatureResult(ctx, id, null, "cyclicPrerequisite");
|
|
1569
|
+
}
|
|
1570
|
+
const evalObj = {
|
|
1571
|
+
value: parentResult.value
|
|
1572
|
+
};
|
|
1573
|
+
const evaled = evalCondition(evalObj, parentCondition.condition || {});
|
|
1574
|
+
if (!evaled) {
|
|
1575
|
+
// blocking prerequisite eval failed: feature evaluation fails
|
|
1576
|
+
if (parentCondition.gate) {
|
|
1577
|
+
return getFeatureResult(ctx, id, null, "prerequisite");
|
|
1578
|
+
}
|
|
1579
|
+
continue rules;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// If there are filters for who is included (e.g. namespaces)
|
|
1585
|
+
if (rule.filters && isFilteredOut(rule.filters, ctx)) {
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Feature value is being forced
|
|
1590
|
+
if ("force" in rule) {
|
|
1591
|
+
// If it's a conditional rule, skip if the condition doesn't pass
|
|
1592
|
+
if (rule.condition && !conditionPasses(rule.condition, ctx)) {
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// If this is a percentage rollout, skip if not included
|
|
1597
|
+
if (!isIncludedInRollout(ctx, rule.seed || id, rule.hashAttribute, ctx.user.saveStickyBucketAssignmentDoc && !rule.disableStickyBucketing ? rule.fallbackAttribute : undefined, rule.range, rule.coverage, rule.hashVersion)) {
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// If this was a remotely evaluated experiment, fire the tracking callbacks
|
|
1602
|
+
if (rule.tracks) {
|
|
1603
|
+
rule.tracks.forEach(t => {
|
|
1604
|
+
const calls = onExperimentViewed(ctx, t.experiment, t.result);
|
|
1605
|
+
if (!calls.length && ctx.global.saveDeferredTrack) {
|
|
1606
|
+
ctx.global.saveDeferredTrack({
|
|
1607
|
+
experiment: t.experiment,
|
|
1608
|
+
result: t.result
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
return getFeatureResult(ctx, id, rule.force, "force", rule.id);
|
|
1614
|
+
}
|
|
1615
|
+
if (!rule.variations) {
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// For experiment rules, run an experiment
|
|
1620
|
+
const exp = {
|
|
1621
|
+
variations: rule.variations,
|
|
1622
|
+
key: rule.key || id
|
|
1623
|
+
};
|
|
1624
|
+
if ("coverage" in rule) exp.coverage = rule.coverage;
|
|
1625
|
+
if (rule.weights) exp.weights = rule.weights;
|
|
1626
|
+
if (rule.hashAttribute) exp.hashAttribute = rule.hashAttribute;
|
|
1627
|
+
if (rule.fallbackAttribute) exp.fallbackAttribute = rule.fallbackAttribute;
|
|
1628
|
+
if (rule.disableStickyBucketing) exp.disableStickyBucketing = rule.disableStickyBucketing;
|
|
1629
|
+
if (rule.bucketVersion !== undefined) exp.bucketVersion = rule.bucketVersion;
|
|
1630
|
+
if (rule.minBucketVersion !== undefined) exp.minBucketVersion = rule.minBucketVersion;
|
|
1631
|
+
if (rule.namespace) exp.namespace = rule.namespace;
|
|
1632
|
+
if (rule.meta) exp.meta = rule.meta;
|
|
1633
|
+
if (rule.ranges) exp.ranges = rule.ranges;
|
|
1634
|
+
if (rule.name) exp.name = rule.name;
|
|
1635
|
+
if (rule.phase) exp.phase = rule.phase;
|
|
1636
|
+
if (rule.seed) exp.seed = rule.seed;
|
|
1637
|
+
if (rule.hashVersion) exp.hashVersion = rule.hashVersion;
|
|
1638
|
+
if (rule.filters) exp.filters = rule.filters;
|
|
1639
|
+
if (rule.condition) exp.condition = rule.condition;
|
|
1640
|
+
|
|
1641
|
+
// Only return a value if the user is part of the experiment
|
|
1642
|
+
const {
|
|
1643
|
+
result
|
|
1644
|
+
} = runExperiment(exp, id, ctx);
|
|
1645
|
+
ctx.global.onExperimentEval && ctx.global.onExperimentEval(exp, result);
|
|
1646
|
+
if (result.inExperiment && !result.passthrough) {
|
|
1647
|
+
return getFeatureResult(ctx, id, result.value, "experiment", rule.id, exp, result);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Fall back to using the default value
|
|
1653
|
+
return getFeatureResult(ctx, id, feature.defaultValue === undefined ? null : feature.defaultValue, "defaultValue");
|
|
1654
|
+
}
|
|
1655
|
+
function runExperiment(experiment, featureId, ctx) {
|
|
1656
|
+
const key = experiment.key;
|
|
1657
|
+
const numVariations = experiment.variations.length;
|
|
1658
|
+
|
|
1659
|
+
// 1. If experiment has less than 2 variations, return immediately
|
|
1660
|
+
if (numVariations < 2) {
|
|
1661
|
+
return {
|
|
1662
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// 2. If the context is disabled, return immediately
|
|
1667
|
+
if (ctx.global.enabled === false || ctx.user.enabled === false) {
|
|
1668
|
+
return {
|
|
1669
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// 2.5. Merge in experiment overrides from the context
|
|
1674
|
+
experiment = mergeOverrides(experiment, ctx);
|
|
1675
|
+
|
|
1676
|
+
// 2.6 New, more powerful URL targeting
|
|
1677
|
+
if (experiment.urlPatterns && !isURLTargeted(ctx.user.url || "", experiment.urlPatterns)) {
|
|
1678
|
+
return {
|
|
1679
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// 3. If a variation is forced from a querystring, return the forced variation
|
|
1684
|
+
const qsOverride = getQueryStringOverride(key, ctx.user.url || "", numVariations);
|
|
1685
|
+
if (qsOverride !== null) {
|
|
1686
|
+
return {
|
|
1687
|
+
result: getExperimentResult(ctx, experiment, qsOverride, false, featureId)
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// 4. If a variation is forced in the context, return the forced variation
|
|
1692
|
+
const forcedVariations = getForcedVariations(ctx);
|
|
1693
|
+
if (key in forcedVariations) {
|
|
1694
|
+
const variation = forcedVariations[key];
|
|
1695
|
+
return {
|
|
1696
|
+
result: getExperimentResult(ctx, experiment, variation, false, featureId)
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// 5. Exclude if a draft experiment or not active
|
|
1701
|
+
if (experiment.status === "draft" || experiment.active === false) {
|
|
1702
|
+
return {
|
|
1703
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// 6. Get the hash attribute and return if empty
|
|
1708
|
+
const {
|
|
1709
|
+
hashAttribute,
|
|
1710
|
+
hashValue
|
|
1711
|
+
} = getHashAttribute(ctx, experiment.hashAttribute, ctx.user.saveStickyBucketAssignmentDoc && !experiment.disableStickyBucketing ? experiment.fallbackAttribute : undefined);
|
|
1712
|
+
if (!hashValue) {
|
|
1713
|
+
return {
|
|
1714
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
let assigned = -1;
|
|
1718
|
+
let foundStickyBucket = false;
|
|
1719
|
+
let stickyBucketVersionIsBlocked = false;
|
|
1720
|
+
if (ctx.user.saveStickyBucketAssignmentDoc && !experiment.disableStickyBucketing) {
|
|
1721
|
+
const {
|
|
1722
|
+
variation,
|
|
1723
|
+
versionIsBlocked
|
|
1724
|
+
} = getStickyBucketVariation({
|
|
1725
|
+
ctx,
|
|
1726
|
+
expKey: experiment.key,
|
|
1727
|
+
expBucketVersion: experiment.bucketVersion,
|
|
1728
|
+
expHashAttribute: experiment.hashAttribute,
|
|
1729
|
+
expFallbackAttribute: experiment.fallbackAttribute,
|
|
1730
|
+
expMinBucketVersion: experiment.minBucketVersion,
|
|
1731
|
+
expMeta: experiment.meta
|
|
1732
|
+
});
|
|
1733
|
+
foundStickyBucket = variation >= 0;
|
|
1734
|
+
assigned = variation;
|
|
1735
|
+
stickyBucketVersionIsBlocked = !!versionIsBlocked;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// Some checks are not needed if we already have a sticky bucket
|
|
1739
|
+
if (!foundStickyBucket) {
|
|
1740
|
+
// 7. Exclude if user is filtered out (used to be called "namespace")
|
|
1741
|
+
if (experiment.filters) {
|
|
1742
|
+
if (isFilteredOut(experiment.filters, ctx)) {
|
|
1743
|
+
return {
|
|
1744
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
} else if (experiment.namespace && !inNamespace(hashValue, experiment.namespace)) {
|
|
1748
|
+
return {
|
|
1749
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// 7.5. Exclude if experiment.include returns false or throws
|
|
1754
|
+
if (experiment.include && !isIncluded(experiment.include)) {
|
|
1755
|
+
return {
|
|
1756
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// 8. Exclude if condition is false
|
|
1761
|
+
if (experiment.condition && !conditionPasses(experiment.condition, ctx)) {
|
|
1762
|
+
return {
|
|
1763
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// 8.05. Exclude if prerequisites are not met
|
|
1768
|
+
if (experiment.parentConditions) {
|
|
1769
|
+
const evaluatedFeatures = new Set(ctx.stack.evaluatedFeatures);
|
|
1770
|
+
for (const parentCondition of experiment.parentConditions) {
|
|
1771
|
+
ctx.stack.evaluatedFeatures = new Set(evaluatedFeatures);
|
|
1772
|
+
const parentResult = evalFeature(parentCondition.id, ctx);
|
|
1773
|
+
// break out for cyclic prerequisites
|
|
1774
|
+
if (parentResult.source === "cyclicPrerequisite") {
|
|
1775
|
+
return {
|
|
1776
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
const evalObj = {
|
|
1780
|
+
value: parentResult.value
|
|
1781
|
+
};
|
|
1782
|
+
if (!evalCondition(evalObj, parentCondition.condition || {})) {
|
|
1783
|
+
return {
|
|
1784
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// 8.1. Exclude if user is not in a required group
|
|
1791
|
+
if (experiment.groups && !hasGroupOverlap(experiment.groups, ctx)) {
|
|
1792
|
+
return {
|
|
1793
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// 8.2. Old style URL targeting
|
|
1799
|
+
if (experiment.url && !urlIsValid(experiment.url, ctx)) {
|
|
1800
|
+
return {
|
|
1801
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// 9. Get the variation from the sticky bucket or get bucket ranges and choose variation
|
|
1806
|
+
const n = hash(experiment.seed || key, hashValue, experiment.hashVersion || 1);
|
|
1807
|
+
if (n === null) {
|
|
1808
|
+
return {
|
|
1809
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
if (!foundStickyBucket) {
|
|
1813
|
+
const ranges = experiment.ranges || getBucketRanges(numVariations, experiment.coverage === undefined ? 1 : experiment.coverage, experiment.weights);
|
|
1814
|
+
assigned = chooseVariation(n, ranges);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// 9.5 Unenroll if any prior sticky buckets are blocked by version
|
|
1818
|
+
if (stickyBucketVersionIsBlocked) {
|
|
1819
|
+
return {
|
|
1820
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId, undefined, true)
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// 10. Return if not in experiment
|
|
1825
|
+
if (assigned < 0) {
|
|
1826
|
+
return {
|
|
1827
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// 11. Experiment has a forced variation
|
|
1832
|
+
if ("force" in experiment) {
|
|
1833
|
+
return {
|
|
1834
|
+
result: getExperimentResult(ctx, experiment, experiment.force === undefined ? -1 : experiment.force, false, featureId)
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// 12. Exclude if in QA mode
|
|
1839
|
+
if (ctx.global.qaMode || ctx.user.qaMode) {
|
|
1840
|
+
return {
|
|
1841
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// 12.5. Exclude if experiment is stopped
|
|
1846
|
+
if (experiment.status === "stopped") {
|
|
1847
|
+
return {
|
|
1848
|
+
result: getExperimentResult(ctx, experiment, -1, false, featureId)
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// 13. Build the result object
|
|
1853
|
+
const result = getExperimentResult(ctx, experiment, assigned, true, featureId, n, foundStickyBucket);
|
|
1854
|
+
|
|
1855
|
+
// 13.5. Persist sticky bucket
|
|
1856
|
+
if (ctx.user.saveStickyBucketAssignmentDoc && !experiment.disableStickyBucketing) {
|
|
1857
|
+
const {
|
|
1858
|
+
changed,
|
|
1859
|
+
key: attrKey,
|
|
1860
|
+
doc
|
|
1861
|
+
} = generateStickyBucketAssignmentDoc(ctx, hashAttribute, toString(hashValue), {
|
|
1862
|
+
[getStickyBucketExperimentKey(experiment.key, experiment.bucketVersion)]: result.key
|
|
1863
|
+
});
|
|
1864
|
+
if (changed) {
|
|
1865
|
+
// update local docs
|
|
1866
|
+
ctx.user.stickyBucketAssignmentDocs = ctx.user.stickyBucketAssignmentDocs || {};
|
|
1867
|
+
ctx.user.stickyBucketAssignmentDocs[attrKey] = doc;
|
|
1868
|
+
// save doc
|
|
1869
|
+
ctx.user.saveStickyBucketAssignmentDoc(doc);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// 14. Fire the tracking callback(s)
|
|
1874
|
+
// Store the promise in case we're awaiting it (ex: browser url redirects)
|
|
1875
|
+
const trackingCalls = onExperimentViewed(ctx, experiment, result);
|
|
1876
|
+
if (trackingCalls.length === 0 && ctx.global.saveDeferredTrack) {
|
|
1877
|
+
ctx.global.saveDeferredTrack({
|
|
1878
|
+
experiment,
|
|
1879
|
+
result
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
const trackingCall = !trackingCalls.length ? undefined : trackingCalls.length === 1 ? trackingCalls[0] : Promise.all(trackingCalls).then(() => {});
|
|
1883
|
+
|
|
1884
|
+
// 14.1 Keep track of completed changeIds
|
|
1885
|
+
"changeId" in experiment && experiment.changeId && ctx.global.recordChangeId && ctx.global.recordChangeId(experiment.changeId);
|
|
1886
|
+
return {
|
|
1887
|
+
result,
|
|
1888
|
+
trackingCall
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
function getFeatureResult(ctx, key, value, source, ruleId, experiment, result) {
|
|
1892
|
+
const ret = {
|
|
1893
|
+
value,
|
|
1894
|
+
on: !!value,
|
|
1895
|
+
off: !value,
|
|
1896
|
+
source,
|
|
1897
|
+
ruleId: ruleId || ""
|
|
1898
|
+
};
|
|
1899
|
+
if (experiment) ret.experiment = experiment;
|
|
1900
|
+
if (result) ret.experimentResult = result;
|
|
1901
|
+
|
|
1902
|
+
// Track the usage of this feature in real-time
|
|
1903
|
+
if (source !== "override") {
|
|
1904
|
+
onFeatureUsage(ctx, key, ret);
|
|
1905
|
+
}
|
|
1906
|
+
return ret;
|
|
1907
|
+
}
|
|
1908
|
+
function getAttributes(ctx) {
|
|
1909
|
+
return {
|
|
1910
|
+
...ctx.user.attributes,
|
|
1911
|
+
...ctx.user.attributeOverrides
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
function conditionPasses(condition, ctx) {
|
|
1915
|
+
return evalCondition(getAttributes(ctx), condition, ctx.global.savedGroups || {});
|
|
1916
|
+
}
|
|
1917
|
+
function isFilteredOut(filters, ctx) {
|
|
1918
|
+
return filters.some(filter => {
|
|
1919
|
+
const {
|
|
1920
|
+
hashValue
|
|
1921
|
+
} = getHashAttribute(ctx, filter.attribute);
|
|
1922
|
+
if (!hashValue) return true;
|
|
1923
|
+
const n = hash(filter.seed, hashValue, filter.hashVersion || 2);
|
|
1924
|
+
if (n === null) return true;
|
|
1925
|
+
return !filter.ranges.some(r => inRange(n, r));
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
function isIncludedInRollout(ctx, seed, hashAttribute, fallbackAttribute, range, coverage, hashVersion) {
|
|
1929
|
+
if (!range && coverage === undefined) return true;
|
|
1930
|
+
if (!range && coverage === 0) return false;
|
|
1931
|
+
const {
|
|
1932
|
+
hashValue
|
|
1933
|
+
} = getHashAttribute(ctx, hashAttribute, fallbackAttribute);
|
|
1934
|
+
if (!hashValue) {
|
|
1935
|
+
return false;
|
|
1936
|
+
}
|
|
1937
|
+
const n = hash(seed, hashValue, hashVersion || 1);
|
|
1938
|
+
if (n === null) return false;
|
|
1939
|
+
return range ? inRange(n, range) : coverage !== undefined ? n <= coverage : true;
|
|
1940
|
+
}
|
|
1941
|
+
function getExperimentResult(ctx, experiment, variationIndex, hashUsed, featureId, bucket, stickyBucketUsed) {
|
|
1942
|
+
let inExperiment = true;
|
|
1943
|
+
// If assigned variation is not valid, use the baseline and mark the user as not in the experiment
|
|
1944
|
+
if (variationIndex < 0 || variationIndex >= experiment.variations.length) {
|
|
1945
|
+
variationIndex = 0;
|
|
1946
|
+
inExperiment = false;
|
|
1947
|
+
}
|
|
1948
|
+
const {
|
|
1949
|
+
hashAttribute,
|
|
1950
|
+
hashValue
|
|
1951
|
+
} = getHashAttribute(ctx, experiment.hashAttribute, ctx.user.saveStickyBucketAssignmentDoc && !experiment.disableStickyBucketing ? experiment.fallbackAttribute : undefined);
|
|
1952
|
+
const meta = experiment.meta ? experiment.meta[variationIndex] : {};
|
|
1953
|
+
const res = {
|
|
1954
|
+
key: meta.key || "" + variationIndex,
|
|
1955
|
+
featureId,
|
|
1956
|
+
inExperiment,
|
|
1957
|
+
hashUsed,
|
|
1958
|
+
variationId: variationIndex,
|
|
1959
|
+
value: experiment.variations[variationIndex],
|
|
1960
|
+
hashAttribute,
|
|
1961
|
+
hashValue,
|
|
1962
|
+
stickyBucketUsed: !!stickyBucketUsed
|
|
1963
|
+
};
|
|
1964
|
+
if (meta.name) res.name = meta.name;
|
|
1965
|
+
if (bucket !== undefined) res.bucket = bucket;
|
|
1966
|
+
if (meta.passthrough) res.passthrough = meta.passthrough;
|
|
1967
|
+
return res;
|
|
1968
|
+
}
|
|
1969
|
+
function mergeOverrides(experiment, ctx) {
|
|
1970
|
+
const key = experiment.key;
|
|
1971
|
+
const o = ctx.global.overrides;
|
|
1972
|
+
if (o && o[key]) {
|
|
1973
|
+
experiment = Object.assign({}, experiment, o[key]);
|
|
1974
|
+
if (typeof experiment.url === "string") {
|
|
1975
|
+
experiment.url = getUrlRegExp(
|
|
1976
|
+
// eslint-disable-next-line
|
|
1977
|
+
experiment.url);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
return experiment;
|
|
1981
|
+
}
|
|
1982
|
+
function getHashAttribute(ctx, attr, fallback) {
|
|
1983
|
+
let hashAttribute = attr || "id";
|
|
1984
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1985
|
+
let hashValue = "";
|
|
1986
|
+
const attributes = getAttributes(ctx);
|
|
1987
|
+
if (attributes[hashAttribute]) {
|
|
1988
|
+
hashValue = attributes[hashAttribute];
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// if no match, try fallback
|
|
1992
|
+
if (!hashValue && fallback) {
|
|
1993
|
+
if (attributes[fallback]) {
|
|
1994
|
+
hashValue = attributes[fallback];
|
|
1995
|
+
}
|
|
1996
|
+
if (hashValue) {
|
|
1997
|
+
hashAttribute = fallback;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return {
|
|
2001
|
+
hashAttribute,
|
|
2002
|
+
hashValue
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
function urlIsValid(urlRegex, ctx) {
|
|
2006
|
+
const url = ctx.user.url;
|
|
2007
|
+
if (!url) return false;
|
|
2008
|
+
const pathOnly = url.replace(/^https?:\/\//, "").replace(/^[^/]*\//, "/");
|
|
2009
|
+
if (urlRegex.test(url)) return true;
|
|
2010
|
+
if (urlRegex.test(pathOnly)) return true;
|
|
2011
|
+
return false;
|
|
2012
|
+
}
|
|
2013
|
+
function hasGroupOverlap(expGroups, ctx) {
|
|
2014
|
+
const groups = ctx.global.groups || {};
|
|
2015
|
+
for (let i = 0; i < expGroups.length; i++) {
|
|
2016
|
+
if (groups[expGroups[i]]) return true;
|
|
2017
|
+
}
|
|
2018
|
+
return false;
|
|
2019
|
+
}
|
|
2020
|
+
function getStickyBucketVariation(_ref) {
|
|
2021
|
+
let {
|
|
2022
|
+
ctx,
|
|
2023
|
+
expKey,
|
|
2024
|
+
expBucketVersion,
|
|
2025
|
+
expHashAttribute,
|
|
2026
|
+
expFallbackAttribute,
|
|
2027
|
+
expMinBucketVersion,
|
|
2028
|
+
expMeta
|
|
2029
|
+
} = _ref;
|
|
2030
|
+
expBucketVersion = expBucketVersion || 0;
|
|
2031
|
+
expMinBucketVersion = expMinBucketVersion || 0;
|
|
2032
|
+
expHashAttribute = expHashAttribute || "id";
|
|
2033
|
+
expMeta = expMeta || [];
|
|
2034
|
+
const id = getStickyBucketExperimentKey(expKey, expBucketVersion);
|
|
2035
|
+
const assignments = getStickyBucketAssignments(ctx, expHashAttribute, expFallbackAttribute);
|
|
2036
|
+
|
|
2037
|
+
// users with any blocked bucket version (0 to minExperimentBucketVersion) are excluded from the test
|
|
2038
|
+
if (expMinBucketVersion > 0) {
|
|
2039
|
+
for (let i = 0; i <= expMinBucketVersion; i++) {
|
|
2040
|
+
const blockedKey = getStickyBucketExperimentKey(expKey, i);
|
|
2041
|
+
if (assignments[blockedKey] !== undefined) {
|
|
2042
|
+
return {
|
|
2043
|
+
variation: -1,
|
|
2044
|
+
versionIsBlocked: true
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
const variationKey = assignments[id];
|
|
2050
|
+
if (variationKey === undefined)
|
|
2051
|
+
// no assignment found
|
|
2052
|
+
return {
|
|
2053
|
+
variation: -1
|
|
2054
|
+
};
|
|
2055
|
+
const variation = expMeta.findIndex(m => m.key === variationKey);
|
|
2056
|
+
if (variation < 0)
|
|
2057
|
+
// invalid assignment, treat as "no assignment found"
|
|
2058
|
+
return {
|
|
2059
|
+
variation: -1
|
|
2060
|
+
};
|
|
2061
|
+
return {
|
|
2062
|
+
variation
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
function getStickyBucketExperimentKey(experimentKey, experimentBucketVersion) {
|
|
2066
|
+
experimentBucketVersion = experimentBucketVersion || 0;
|
|
2067
|
+
return `${experimentKey}__${experimentBucketVersion}`;
|
|
2068
|
+
}
|
|
2069
|
+
function getStickyBucketAttributeKey(attributeName, attributeValue) {
|
|
2070
|
+
return `${attributeName}||${attributeValue}`;
|
|
2071
|
+
}
|
|
2072
|
+
function getStickyBucketAssignments(ctx, expHashAttribute, expFallbackAttribute) {
|
|
2073
|
+
if (!ctx.user.stickyBucketAssignmentDocs) return {};
|
|
2074
|
+
const {
|
|
2075
|
+
hashAttribute,
|
|
2076
|
+
hashValue
|
|
2077
|
+
} = getHashAttribute(ctx, expHashAttribute);
|
|
2078
|
+
const hashKey = getStickyBucketAttributeKey(hashAttribute, toString(hashValue));
|
|
2079
|
+
const {
|
|
2080
|
+
hashAttribute: fallbackAttribute,
|
|
2081
|
+
hashValue: fallbackValue
|
|
2082
|
+
} = getHashAttribute(ctx, expFallbackAttribute);
|
|
2083
|
+
const fallbackKey = fallbackValue ? getStickyBucketAttributeKey(fallbackAttribute, toString(fallbackValue)) : null;
|
|
2084
|
+
const assignments = {};
|
|
2085
|
+
if (fallbackKey && ctx.user.stickyBucketAssignmentDocs[fallbackKey]) {
|
|
2086
|
+
Object.assign(assignments, ctx.user.stickyBucketAssignmentDocs[fallbackKey].assignments || {});
|
|
2087
|
+
}
|
|
2088
|
+
if (ctx.user.stickyBucketAssignmentDocs[hashKey]) {
|
|
2089
|
+
Object.assign(assignments, ctx.user.stickyBucketAssignmentDocs[hashKey].assignments || {});
|
|
2090
|
+
}
|
|
2091
|
+
return assignments;
|
|
2092
|
+
}
|
|
2093
|
+
function generateStickyBucketAssignmentDoc(ctx, attributeName, attributeValue, assignments) {
|
|
2094
|
+
const key = getStickyBucketAttributeKey(attributeName, attributeValue);
|
|
2095
|
+
const existingAssignments = ctx.user.stickyBucketAssignmentDocs && ctx.user.stickyBucketAssignmentDocs[key] ? ctx.user.stickyBucketAssignmentDocs[key].assignments || {} : {};
|
|
2096
|
+
const newAssignments = {
|
|
2097
|
+
...existingAssignments,
|
|
2098
|
+
...assignments
|
|
2099
|
+
};
|
|
2100
|
+
const changed = JSON.stringify(existingAssignments) !== JSON.stringify(newAssignments);
|
|
2101
|
+
return {
|
|
2102
|
+
key,
|
|
2103
|
+
doc: {
|
|
2104
|
+
attributeName,
|
|
2105
|
+
attributeValue,
|
|
2106
|
+
assignments: newAssignments
|
|
2107
|
+
},
|
|
2108
|
+
changed
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
function deriveStickyBucketIdentifierAttributes(ctx, data) {
|
|
2112
|
+
const attributes = new Set();
|
|
2113
|
+
const features = data && data.features ? data.features : ctx.global.features || {};
|
|
2114
|
+
const experiments = data && data.experiments ? data.experiments : ctx.global.experiments || [];
|
|
2115
|
+
Object.keys(features).forEach(id => {
|
|
2116
|
+
const feature = features[id];
|
|
2117
|
+
if (feature.rules) {
|
|
2118
|
+
for (const rule of feature.rules) {
|
|
2119
|
+
if (rule.variations) {
|
|
2120
|
+
attributes.add(rule.hashAttribute || "id");
|
|
2121
|
+
if (rule.fallbackAttribute) {
|
|
2122
|
+
attributes.add(rule.fallbackAttribute);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
experiments.map(experiment => {
|
|
2129
|
+
attributes.add(experiment.hashAttribute || "id");
|
|
2130
|
+
if (experiment.fallbackAttribute) {
|
|
2131
|
+
attributes.add(experiment.fallbackAttribute);
|
|
2132
|
+
}
|
|
2133
|
+
});
|
|
2134
|
+
return Array.from(attributes);
|
|
2135
|
+
}
|
|
2136
|
+
async function getAllStickyBucketAssignmentDocs(ctx, stickyBucketService, data) {
|
|
2137
|
+
const attributes = getStickyBucketAttributes(ctx, data);
|
|
2138
|
+
return stickyBucketService.getAllAssignments(attributes);
|
|
2139
|
+
}
|
|
2140
|
+
function getStickyBucketAttributes(ctx, data) {
|
|
2141
|
+
const attributes = {};
|
|
2142
|
+
const stickyBucketIdentifierAttributes = deriveStickyBucketIdentifierAttributes(ctx, data);
|
|
2143
|
+
stickyBucketIdentifierAttributes.forEach(attr => {
|
|
2144
|
+
const {
|
|
2145
|
+
hashValue
|
|
2146
|
+
} = getHashAttribute(ctx, attr);
|
|
2147
|
+
attributes[attr] = toString(hashValue);
|
|
2148
|
+
});
|
|
2149
|
+
return attributes;
|
|
2150
|
+
}
|
|
2151
|
+
async function decryptPayload(data, decryptionKey, subtle) {
|
|
2152
|
+
data = {
|
|
2153
|
+
...data
|
|
2154
|
+
};
|
|
2155
|
+
if (data.encryptedFeatures) {
|
|
2156
|
+
try {
|
|
2157
|
+
data.features = JSON.parse(await decrypt(data.encryptedFeatures, decryptionKey, subtle));
|
|
2158
|
+
} catch (e) {
|
|
2159
|
+
console.error(e);
|
|
2160
|
+
}
|
|
2161
|
+
delete data.encryptedFeatures;
|
|
2162
|
+
}
|
|
2163
|
+
if (data.encryptedExperiments) {
|
|
2164
|
+
try {
|
|
2165
|
+
data.experiments = JSON.parse(await decrypt(data.encryptedExperiments, decryptionKey, subtle));
|
|
2166
|
+
} catch (e) {
|
|
2167
|
+
console.error(e);
|
|
2168
|
+
}
|
|
2169
|
+
delete data.encryptedExperiments;
|
|
2170
|
+
}
|
|
2171
|
+
if (data.encryptedSavedGroups) {
|
|
2172
|
+
try {
|
|
2173
|
+
data.savedGroups = JSON.parse(await decrypt(data.encryptedSavedGroups, decryptionKey, subtle));
|
|
2174
|
+
} catch (e) {
|
|
2175
|
+
console.error(e);
|
|
2176
|
+
}
|
|
2177
|
+
delete data.encryptedSavedGroups;
|
|
2178
|
+
}
|
|
2179
|
+
return data;
|
|
2180
|
+
}
|
|
2181
|
+
function getApiHosts(options) {
|
|
2182
|
+
const defaultHost = options.apiHost || "https://cdn.growthbook.io";
|
|
2183
|
+
return {
|
|
2184
|
+
apiHost: defaultHost.replace(/\/*$/, ""),
|
|
2185
|
+
streamingHost: (options.streamingHost || defaultHost).replace(/\/*$/, ""),
|
|
2186
|
+
apiRequestHeaders: options.apiHostRequestHeaders,
|
|
2187
|
+
streamingHostRequestHeaders: options.streamingHostRequestHeaders
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
function getExperimentDedupeKey(experiment, result) {
|
|
2191
|
+
return result.hashAttribute + result.hashValue + experiment.key + result.variationId;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
2195
|
+
const SDK_VERSION$1 = loadSDKVersion();
|
|
2196
|
+
class GrowthBook {
|
|
2197
|
+
// context is technically private, but some tools depend on it so we can't mangle the name
|
|
2198
|
+
|
|
2199
|
+
// Properties and methods that start with "_" are mangled by Terser (saves ~150 bytes)
|
|
2200
|
+
|
|
2201
|
+
constructor(options) {
|
|
2202
|
+
options = options || {};
|
|
2203
|
+
// These properties are all initialized in the constructor instead of above
|
|
2204
|
+
// This saves ~80 bytes in the final output
|
|
2205
|
+
this.version = SDK_VERSION$1;
|
|
2206
|
+
this._options = this.context = options;
|
|
2207
|
+
this._renderer = options.renderer || null;
|
|
2208
|
+
this._trackedExperiments = new Set();
|
|
2209
|
+
this._completedChangeIds = new Set();
|
|
2210
|
+
this._trackedFeatures = {};
|
|
2211
|
+
this.debug = !!options.debug;
|
|
2212
|
+
this._subscriptions = new Set();
|
|
2213
|
+
this.ready = false;
|
|
2214
|
+
this._assigned = new Map();
|
|
2215
|
+
this._activeAutoExperiments = new Map();
|
|
2216
|
+
this._triggeredExpKeys = new Set();
|
|
2217
|
+
this._initialized = false;
|
|
2218
|
+
this._redirectedUrl = "";
|
|
2219
|
+
this._deferredTrackingCalls = new Map();
|
|
2220
|
+
this._autoExperimentsAllowed = !options.disableExperimentsOnLoad;
|
|
2221
|
+
this._destroyCallbacks = [];
|
|
2222
|
+
this.logs = [];
|
|
2223
|
+
this.log = this.log.bind(this);
|
|
2224
|
+
this._saveDeferredTrack = this._saveDeferredTrack.bind(this);
|
|
2225
|
+
this._fireSubscriptions = this._fireSubscriptions.bind(this);
|
|
2226
|
+
this._recordChangedId = this._recordChangedId.bind(this);
|
|
2227
|
+
if (options.remoteEval) {
|
|
2228
|
+
if (options.decryptionKey) {
|
|
2229
|
+
throw new Error("Encryption is not available for remoteEval");
|
|
2230
|
+
}
|
|
2231
|
+
if (!options.clientKey) {
|
|
2232
|
+
throw new Error("Missing clientKey");
|
|
2233
|
+
}
|
|
2234
|
+
let isGbHost = false;
|
|
2235
|
+
try {
|
|
2236
|
+
isGbHost = !!new URL(options.apiHost || "").hostname.match(/growthbook\.io$/i);
|
|
2237
|
+
} catch (e) {
|
|
2238
|
+
// ignore invalid URLs
|
|
2239
|
+
}
|
|
2240
|
+
if (isGbHost) {
|
|
2241
|
+
throw new Error("Cannot use remoteEval on GrowthBook Cloud");
|
|
2242
|
+
}
|
|
2243
|
+
} else {
|
|
2244
|
+
if (options.cacheKeyAttributes) {
|
|
2245
|
+
throw new Error("cacheKeyAttributes are only used for remoteEval");
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
if (options.stickyBucketService) {
|
|
2249
|
+
const s = options.stickyBucketService;
|
|
2250
|
+
this._saveStickyBucketAssignmentDoc = doc => {
|
|
2251
|
+
return s.saveAssignments(doc);
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
if (options.plugins) {
|
|
2255
|
+
for (const plugin of options.plugins) {
|
|
2256
|
+
plugin(this);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
if (options.features) {
|
|
2260
|
+
this.ready = true;
|
|
2261
|
+
}
|
|
2262
|
+
if (isBrowser && options.enableDevMode) {
|
|
2263
|
+
window._growthbook = this;
|
|
2264
|
+
document.dispatchEvent(new Event("gbloaded"));
|
|
2265
|
+
}
|
|
2266
|
+
if (options.experiments) {
|
|
2267
|
+
this.ready = true;
|
|
2268
|
+
this._updateAllAutoExperiments();
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// Hydrate sticky bucket service
|
|
2272
|
+
if (this._options.stickyBucketService && this._options.stickyBucketAssignmentDocs) {
|
|
2273
|
+
for (const key in this._options.stickyBucketAssignmentDocs) {
|
|
2274
|
+
const doc = this._options.stickyBucketAssignmentDocs[key];
|
|
2275
|
+
if (doc) {
|
|
2276
|
+
this._options.stickyBucketService.saveAssignments(doc).catch(() => {
|
|
2277
|
+
// Ignore hydration errors
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// Legacy - passing in features/experiments into the constructor instead of using init
|
|
2284
|
+
if (this.ready) {
|
|
2285
|
+
this.refreshStickyBuckets(this.getPayload());
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
async setPayload(payload) {
|
|
2289
|
+
this._payload = payload;
|
|
2290
|
+
const data = await decryptPayload(payload, this._options.decryptionKey);
|
|
2291
|
+
this._decryptedPayload = data;
|
|
2292
|
+
await this.refreshStickyBuckets(data);
|
|
2293
|
+
if (data.features) {
|
|
2294
|
+
this._options.features = data.features;
|
|
2295
|
+
}
|
|
2296
|
+
if (data.savedGroups) {
|
|
2297
|
+
this._options.savedGroups = data.savedGroups;
|
|
2298
|
+
}
|
|
2299
|
+
if (data.experiments) {
|
|
2300
|
+
this._options.experiments = data.experiments;
|
|
2301
|
+
this._updateAllAutoExperiments();
|
|
2302
|
+
}
|
|
2303
|
+
this.ready = true;
|
|
2304
|
+
this._render();
|
|
2305
|
+
}
|
|
2306
|
+
initSync(options) {
|
|
2307
|
+
this._initialized = true;
|
|
2308
|
+
const payload = options.payload;
|
|
2309
|
+
if (payload.encryptedExperiments || payload.encryptedFeatures) {
|
|
2310
|
+
throw new Error("initSync does not support encrypted payloads");
|
|
2311
|
+
}
|
|
2312
|
+
if (this._options.stickyBucketService && !this._options.stickyBucketAssignmentDocs) {
|
|
2313
|
+
this._options.stickyBucketAssignmentDocs = this.generateStickyBucketAssignmentDocsSync(this._options.stickyBucketService, payload);
|
|
2314
|
+
}
|
|
2315
|
+
this._payload = payload;
|
|
2316
|
+
this._decryptedPayload = payload;
|
|
2317
|
+
if (payload.features) {
|
|
2318
|
+
this._options.features = payload.features;
|
|
2319
|
+
}
|
|
2320
|
+
if (payload.experiments) {
|
|
2321
|
+
this._options.experiments = payload.experiments;
|
|
2322
|
+
this._updateAllAutoExperiments();
|
|
2323
|
+
}
|
|
2324
|
+
this.ready = true;
|
|
2325
|
+
startStreaming(this, options);
|
|
2326
|
+
return this;
|
|
2327
|
+
}
|
|
2328
|
+
async init(options) {
|
|
2329
|
+
this._initialized = true;
|
|
2330
|
+
options = options || {};
|
|
2331
|
+
if (options.cacheSettings) {
|
|
2332
|
+
configureCache(options.cacheSettings);
|
|
2333
|
+
}
|
|
2334
|
+
if (options.payload) {
|
|
2335
|
+
await this.setPayload(options.payload);
|
|
2336
|
+
startStreaming(this, options);
|
|
2337
|
+
return {
|
|
2338
|
+
success: true,
|
|
2339
|
+
source: "init"
|
|
2340
|
+
};
|
|
2341
|
+
} else {
|
|
2342
|
+
const {
|
|
2343
|
+
data,
|
|
2344
|
+
...res
|
|
2345
|
+
} = await this._refresh({
|
|
2346
|
+
...options,
|
|
2347
|
+
allowStale: true
|
|
2348
|
+
});
|
|
2349
|
+
startStreaming(this, options);
|
|
2350
|
+
await this.setPayload(data || {});
|
|
2351
|
+
return res;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
/** @deprecated Use {@link init} */
|
|
2356
|
+
async loadFeatures(options) {
|
|
2357
|
+
options = options || {};
|
|
2358
|
+
await this.init({
|
|
2359
|
+
skipCache: options.skipCache,
|
|
2360
|
+
timeout: options.timeout,
|
|
2361
|
+
streaming: (this._options.backgroundSync ?? true) && (options.autoRefresh || this._options.subscribeToChanges)
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
async refreshFeatures(options) {
|
|
2365
|
+
const res = await this._refresh({
|
|
2366
|
+
...(options || {}),
|
|
2367
|
+
allowStale: false
|
|
2368
|
+
});
|
|
2369
|
+
if (res.data) {
|
|
2370
|
+
await this.setPayload(res.data);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
getApiInfo() {
|
|
2374
|
+
return [this.getApiHosts().apiHost, this.getClientKey()];
|
|
2375
|
+
}
|
|
2376
|
+
getApiHosts() {
|
|
2377
|
+
return getApiHosts(this._options);
|
|
2378
|
+
}
|
|
2379
|
+
getClientKey() {
|
|
2380
|
+
return this._options.clientKey || "";
|
|
2381
|
+
}
|
|
2382
|
+
getPayload() {
|
|
2383
|
+
return this._payload || {
|
|
2384
|
+
features: this.getFeatures(),
|
|
2385
|
+
experiments: this.getExperiments()
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
getDecryptedPayload() {
|
|
2389
|
+
return this._decryptedPayload || this.getPayload();
|
|
2390
|
+
}
|
|
2391
|
+
isRemoteEval() {
|
|
2392
|
+
return this._options.remoteEval || false;
|
|
2393
|
+
}
|
|
2394
|
+
getCacheKeyAttributes() {
|
|
2395
|
+
return this._options.cacheKeyAttributes;
|
|
2396
|
+
}
|
|
2397
|
+
async _refresh(_ref) {
|
|
2398
|
+
let {
|
|
2399
|
+
timeout,
|
|
2400
|
+
skipCache,
|
|
2401
|
+
allowStale,
|
|
2402
|
+
streaming
|
|
2403
|
+
} = _ref;
|
|
2404
|
+
if (!this._options.clientKey) {
|
|
2405
|
+
throw new Error("Missing clientKey");
|
|
2406
|
+
}
|
|
2407
|
+
// Trigger refresh in feature repository
|
|
2408
|
+
return refreshFeatures({
|
|
2409
|
+
instance: this,
|
|
2410
|
+
timeout,
|
|
2411
|
+
skipCache: skipCache || this._options.disableCache,
|
|
2412
|
+
allowStale,
|
|
2413
|
+
backgroundSync: streaming ?? this._options.backgroundSync ?? true
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
_render() {
|
|
2417
|
+
if (this._renderer) {
|
|
2418
|
+
try {
|
|
2419
|
+
this._renderer();
|
|
2420
|
+
} catch (e) {
|
|
2421
|
+
console.error("Failed to render", e);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
/** @deprecated Use {@link setPayload} */
|
|
2427
|
+
setFeatures(features) {
|
|
2428
|
+
this._options.features = features;
|
|
2429
|
+
this.ready = true;
|
|
2430
|
+
this._render();
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
/** @deprecated Use {@link setPayload} */
|
|
2434
|
+
async setEncryptedFeatures(encryptedString, decryptionKey, subtle) {
|
|
2435
|
+
const featuresJSON = await decrypt(encryptedString, decryptionKey || this._options.decryptionKey, subtle);
|
|
2436
|
+
this.setFeatures(JSON.parse(featuresJSON));
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/** @deprecated Use {@link setPayload} */
|
|
2440
|
+
setExperiments(experiments) {
|
|
2441
|
+
this._options.experiments = experiments;
|
|
2442
|
+
this.ready = true;
|
|
2443
|
+
this._updateAllAutoExperiments();
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/** @deprecated Use {@link setPayload} */
|
|
2447
|
+
async setEncryptedExperiments(encryptedString, decryptionKey, subtle) {
|
|
2448
|
+
const experimentsJSON = await decrypt(encryptedString, decryptionKey || this._options.decryptionKey, subtle);
|
|
2449
|
+
this.setExperiments(JSON.parse(experimentsJSON));
|
|
2450
|
+
}
|
|
2451
|
+
async setAttributes(attributes) {
|
|
2452
|
+
this._options.attributes = attributes;
|
|
2453
|
+
if (this._options.stickyBucketService) {
|
|
2454
|
+
await this.refreshStickyBuckets();
|
|
2455
|
+
}
|
|
2456
|
+
if (this._options.remoteEval) {
|
|
2457
|
+
await this._refreshForRemoteEval();
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
this._render();
|
|
2461
|
+
this._updateAllAutoExperiments();
|
|
2462
|
+
}
|
|
2463
|
+
async updateAttributes(attributes) {
|
|
2464
|
+
return this.setAttributes({
|
|
2465
|
+
...this._options.attributes,
|
|
2466
|
+
...attributes
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
async setAttributeOverrides(overrides) {
|
|
2470
|
+
this._options.attributeOverrides = overrides;
|
|
2471
|
+
if (this._options.stickyBucketService) {
|
|
2472
|
+
await this.refreshStickyBuckets();
|
|
2473
|
+
}
|
|
2474
|
+
if (this._options.remoteEval) {
|
|
2475
|
+
await this._refreshForRemoteEval();
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
this._render();
|
|
2479
|
+
this._updateAllAutoExperiments();
|
|
2480
|
+
}
|
|
2481
|
+
async setForcedVariations(vars) {
|
|
2482
|
+
this._options.forcedVariations = vars || {};
|
|
2483
|
+
if (this._options.remoteEval) {
|
|
2484
|
+
await this._refreshForRemoteEval();
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
this._render();
|
|
2488
|
+
this._updateAllAutoExperiments();
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// eslint-disable-next-line
|
|
2492
|
+
setForcedFeatures(map) {
|
|
2493
|
+
this._options.forcedFeatureValues = map;
|
|
2494
|
+
this._render();
|
|
2495
|
+
}
|
|
2496
|
+
async setURL(url) {
|
|
2497
|
+
if (url === this._options.url) return;
|
|
2498
|
+
this._options.url = url;
|
|
2499
|
+
this._redirectedUrl = "";
|
|
2500
|
+
if (this._options.remoteEval) {
|
|
2501
|
+
await this._refreshForRemoteEval();
|
|
2502
|
+
this._updateAllAutoExperiments(true);
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
this._updateAllAutoExperiments(true);
|
|
2506
|
+
}
|
|
2507
|
+
getAttributes() {
|
|
2508
|
+
return {
|
|
2509
|
+
...this._options.attributes,
|
|
2510
|
+
...this._options.attributeOverrides
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
getForcedVariations() {
|
|
2514
|
+
return this._options.forcedVariations || {};
|
|
2515
|
+
}
|
|
2516
|
+
getForcedFeatures() {
|
|
2517
|
+
// eslint-disable-next-line
|
|
2518
|
+
return this._options.forcedFeatureValues || new Map();
|
|
2519
|
+
}
|
|
2520
|
+
getStickyBucketAssignmentDocs() {
|
|
2521
|
+
return this._options.stickyBucketAssignmentDocs || {};
|
|
2522
|
+
}
|
|
2523
|
+
getUrl() {
|
|
2524
|
+
return this._options.url || "";
|
|
2525
|
+
}
|
|
2526
|
+
getFeatures() {
|
|
2527
|
+
return this._options.features || {};
|
|
2528
|
+
}
|
|
2529
|
+
getExperiments() {
|
|
2530
|
+
return this._options.experiments || [];
|
|
2531
|
+
}
|
|
2532
|
+
getCompletedChangeIds() {
|
|
2533
|
+
return Array.from(this._completedChangeIds);
|
|
2534
|
+
}
|
|
2535
|
+
subscribe(cb) {
|
|
2536
|
+
this._subscriptions.add(cb);
|
|
2537
|
+
return () => {
|
|
2538
|
+
this._subscriptions.delete(cb);
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
async _refreshForRemoteEval() {
|
|
2542
|
+
if (!this._options.remoteEval) return;
|
|
2543
|
+
if (!this._initialized) return;
|
|
2544
|
+
const res = await this._refresh({
|
|
2545
|
+
allowStale: false
|
|
2546
|
+
});
|
|
2547
|
+
if (res.data) {
|
|
2548
|
+
await this.setPayload(res.data);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
getAllResults() {
|
|
2552
|
+
return new Map(this._assigned);
|
|
2553
|
+
}
|
|
2554
|
+
onDestroy(cb) {
|
|
2555
|
+
this._destroyCallbacks.push(cb);
|
|
2556
|
+
}
|
|
2557
|
+
isDestroyed() {
|
|
2558
|
+
return !!this._destroyed;
|
|
2559
|
+
}
|
|
2560
|
+
destroy() {
|
|
2561
|
+
this._destroyed = true;
|
|
2562
|
+
|
|
2563
|
+
// Custom callbacks
|
|
2564
|
+
// Do this first in case it needs access to the below data that is cleared
|
|
2565
|
+
this._destroyCallbacks.forEach(cb => {
|
|
2566
|
+
try {
|
|
2567
|
+
cb();
|
|
2568
|
+
} catch (e) {
|
|
2569
|
+
console.error(e);
|
|
2570
|
+
}
|
|
2571
|
+
});
|
|
2572
|
+
|
|
2573
|
+
// Release references to save memory
|
|
2574
|
+
this._subscriptions.clear();
|
|
2575
|
+
this._assigned.clear();
|
|
2576
|
+
this._trackedExperiments.clear();
|
|
2577
|
+
this._completedChangeIds.clear();
|
|
2578
|
+
this._deferredTrackingCalls.clear();
|
|
2579
|
+
this._trackedFeatures = {};
|
|
2580
|
+
this._destroyCallbacks = [];
|
|
2581
|
+
this._payload = undefined;
|
|
2582
|
+
this._saveStickyBucketAssignmentDoc = undefined;
|
|
2583
|
+
unsubscribe(this);
|
|
2584
|
+
this.logs = [];
|
|
2585
|
+
if (isBrowser && window._growthbook === this) {
|
|
2586
|
+
delete window._growthbook;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
// Undo any active auto experiments
|
|
2590
|
+
this._activeAutoExperiments.forEach(exp => {
|
|
2591
|
+
exp.undo();
|
|
2592
|
+
});
|
|
2593
|
+
this._activeAutoExperiments.clear();
|
|
2594
|
+
this._triggeredExpKeys.clear();
|
|
2595
|
+
}
|
|
2596
|
+
setRenderer(renderer) {
|
|
2597
|
+
this._renderer = renderer;
|
|
2598
|
+
}
|
|
2599
|
+
forceVariation(key, variation) {
|
|
2600
|
+
this._options.forcedVariations = this._options.forcedVariations || {};
|
|
2601
|
+
this._options.forcedVariations[key] = variation;
|
|
2602
|
+
if (this._options.remoteEval) {
|
|
2603
|
+
this._refreshForRemoteEval();
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
this._updateAllAutoExperiments();
|
|
2607
|
+
this._render();
|
|
2608
|
+
}
|
|
2609
|
+
run(experiment) {
|
|
2610
|
+
const {
|
|
2611
|
+
result
|
|
2612
|
+
} = runExperiment(experiment, null, this._getEvalContext());
|
|
2613
|
+
this._fireSubscriptions(experiment, result);
|
|
2614
|
+
return result;
|
|
2615
|
+
}
|
|
2616
|
+
triggerExperiment(key) {
|
|
2617
|
+
this._triggeredExpKeys.add(key);
|
|
2618
|
+
if (!this._options.experiments) return null;
|
|
2619
|
+
const experiments = this._options.experiments.filter(exp => exp.key === key);
|
|
2620
|
+
return experiments.map(exp => {
|
|
2621
|
+
return this._runAutoExperiment(exp);
|
|
2622
|
+
}).filter(res => res !== null);
|
|
2623
|
+
}
|
|
2624
|
+
triggerAutoExperiments() {
|
|
2625
|
+
this._autoExperimentsAllowed = true;
|
|
2626
|
+
this._updateAllAutoExperiments(true);
|
|
2627
|
+
}
|
|
2628
|
+
_getEvalContext() {
|
|
2629
|
+
return {
|
|
2630
|
+
user: this._getUserContext(),
|
|
2631
|
+
global: this._getGlobalContext(),
|
|
2632
|
+
stack: {
|
|
2633
|
+
evaluatedFeatures: new Set()
|
|
2634
|
+
}
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
_getUserContext() {
|
|
2638
|
+
return {
|
|
2639
|
+
attributes: this._options.user ? {
|
|
2640
|
+
...this._options.user,
|
|
2641
|
+
...this._options.attributes
|
|
2642
|
+
} : this._options.attributes,
|
|
2643
|
+
enableDevMode: this._options.enableDevMode,
|
|
2644
|
+
blockedChangeIds: this._options.blockedChangeIds,
|
|
2645
|
+
stickyBucketAssignmentDocs: this._options.stickyBucketAssignmentDocs,
|
|
2646
|
+
url: this._getContextUrl(),
|
|
2647
|
+
forcedVariations: this._options.forcedVariations,
|
|
2648
|
+
forcedFeatureValues: this._options.forcedFeatureValues,
|
|
2649
|
+
attributeOverrides: this._options.attributeOverrides,
|
|
2650
|
+
saveStickyBucketAssignmentDoc: this._saveStickyBucketAssignmentDoc,
|
|
2651
|
+
trackingCallback: this._options.trackingCallback,
|
|
2652
|
+
onFeatureUsage: this._options.onFeatureUsage,
|
|
2653
|
+
devLogs: this.logs,
|
|
2654
|
+
trackedExperiments: this._trackedExperiments,
|
|
2655
|
+
trackedFeatureUsage: this._trackedFeatures
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
_getGlobalContext() {
|
|
2659
|
+
return {
|
|
2660
|
+
features: this._options.features,
|
|
2661
|
+
experiments: this._options.experiments,
|
|
2662
|
+
log: this.log,
|
|
2663
|
+
enabled: this._options.enabled,
|
|
2664
|
+
qaMode: this._options.qaMode,
|
|
2665
|
+
savedGroups: this._options.savedGroups,
|
|
2666
|
+
groups: this._options.groups,
|
|
2667
|
+
overrides: this._options.overrides,
|
|
2668
|
+
onExperimentEval: this._subscriptions.size > 0 ? this._fireSubscriptions : undefined,
|
|
2669
|
+
recordChangeId: this._recordChangedId,
|
|
2670
|
+
saveDeferredTrack: this._saveDeferredTrack,
|
|
2671
|
+
eventLogger: this._options.eventLogger
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
_runAutoExperiment(experiment, forceRerun) {
|
|
2675
|
+
const existing = this._activeAutoExperiments.get(experiment);
|
|
2676
|
+
|
|
2677
|
+
// If this is a manual experiment and it's not already running, skip
|
|
2678
|
+
if (experiment.manual && !this._triggeredExpKeys.has(experiment.key) && !existing) return null;
|
|
2679
|
+
|
|
2680
|
+
// Check if this particular experiment is blocked by options settings
|
|
2681
|
+
// For example, if all visualEditor experiments are disabled
|
|
2682
|
+
const isBlocked = this._isAutoExperimentBlockedByContext(experiment);
|
|
2683
|
+
let result;
|
|
2684
|
+
let trackingCall;
|
|
2685
|
+
// Run the experiment (if blocked exclude)
|
|
2686
|
+
if (isBlocked) {
|
|
2687
|
+
result = getExperimentResult(this._getEvalContext(), experiment, -1, false, "");
|
|
2688
|
+
} else {
|
|
2689
|
+
({
|
|
2690
|
+
result,
|
|
2691
|
+
trackingCall
|
|
2692
|
+
} = runExperiment(experiment, null, this._getEvalContext()));
|
|
2693
|
+
this._fireSubscriptions(experiment, result);
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
// A hash to quickly tell if the assigned value changed
|
|
2697
|
+
const valueHash = JSON.stringify(result.value);
|
|
2698
|
+
|
|
2699
|
+
// If the changes are already active, no need to re-apply them
|
|
2700
|
+
if (!forceRerun && result.inExperiment && existing && existing.valueHash === valueHash) {
|
|
2701
|
+
return result;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// Undo any existing changes
|
|
2705
|
+
if (existing) this._undoActiveAutoExperiment(experiment);
|
|
2706
|
+
|
|
2707
|
+
// Apply new changes
|
|
2708
|
+
if (result.inExperiment) {
|
|
2709
|
+
const changeType = getAutoExperimentChangeType(experiment);
|
|
2710
|
+
if (changeType === "redirect" && result.value.urlRedirect && experiment.urlPatterns) {
|
|
2711
|
+
const url = experiment.persistQueryString ? mergeQueryStrings(this._getContextUrl(), result.value.urlRedirect) : result.value.urlRedirect;
|
|
2712
|
+
if (isURLTargeted(url, experiment.urlPatterns)) {
|
|
2713
|
+
this.log("Skipping redirect because original URL matches redirect URL", {
|
|
2714
|
+
id: experiment.key
|
|
2715
|
+
});
|
|
2716
|
+
return result;
|
|
2717
|
+
}
|
|
2718
|
+
this._redirectedUrl = url;
|
|
2719
|
+
const {
|
|
2720
|
+
navigate,
|
|
2721
|
+
delay
|
|
2722
|
+
} = this._getNavigateFunction();
|
|
2723
|
+
if (navigate) {
|
|
2724
|
+
if (isBrowser) {
|
|
2725
|
+
// Wait for the possibly-async tracking callback, bound by min and max delays
|
|
2726
|
+
Promise.all([...(trackingCall ? [promiseTimeout(trackingCall, this._options.maxNavigateDelay ?? 1000)] : []), new Promise(resolve => window.setTimeout(resolve, this._options.navigateDelay ?? delay))]).then(() => {
|
|
2727
|
+
try {
|
|
2728
|
+
navigate(url);
|
|
2729
|
+
} catch (e) {
|
|
2730
|
+
console.error(e);
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
} else {
|
|
2734
|
+
try {
|
|
2735
|
+
navigate(url);
|
|
2736
|
+
} catch (e) {
|
|
2737
|
+
console.error(e);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
} else if (changeType === "visual") {
|
|
2742
|
+
const undo = this._options.applyDomChangesCallback ? this._options.applyDomChangesCallback(result.value) : this._applyDOMChanges(result.value);
|
|
2743
|
+
if (undo) {
|
|
2744
|
+
this._activeAutoExperiments.set(experiment, {
|
|
2745
|
+
undo,
|
|
2746
|
+
valueHash
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
return result;
|
|
2752
|
+
}
|
|
2753
|
+
_undoActiveAutoExperiment(exp) {
|
|
2754
|
+
const data = this._activeAutoExperiments.get(exp);
|
|
2755
|
+
if (data) {
|
|
2756
|
+
data.undo();
|
|
2757
|
+
this._activeAutoExperiments.delete(exp);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
_updateAllAutoExperiments(forceRerun) {
|
|
2761
|
+
if (!this._autoExperimentsAllowed) return;
|
|
2762
|
+
const experiments = this._options.experiments || [];
|
|
2763
|
+
|
|
2764
|
+
// Stop any experiments that are no longer defined
|
|
2765
|
+
const keys = new Set(experiments);
|
|
2766
|
+
this._activeAutoExperiments.forEach((v, k) => {
|
|
2767
|
+
if (!keys.has(k)) {
|
|
2768
|
+
v.undo();
|
|
2769
|
+
this._activeAutoExperiments.delete(k);
|
|
2770
|
+
}
|
|
2771
|
+
});
|
|
2772
|
+
|
|
2773
|
+
// Re-run all new/updated experiments
|
|
2774
|
+
for (const exp of experiments) {
|
|
2775
|
+
const result = this._runAutoExperiment(exp, forceRerun);
|
|
2776
|
+
|
|
2777
|
+
// Once you're in a redirect experiment, break out of the loop and don't run any further experiments
|
|
2778
|
+
if (result !== null && result !== void 0 && result.inExperiment && getAutoExperimentChangeType(exp) === "redirect") {
|
|
2779
|
+
break;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
_fireSubscriptions(experiment, result) {
|
|
2784
|
+
const key = experiment.key;
|
|
2785
|
+
|
|
2786
|
+
// If assigned variation has changed, fire subscriptions
|
|
2787
|
+
const prev = this._assigned.get(key);
|
|
2788
|
+
// TODO: what if the experiment definition has changed?
|
|
2789
|
+
if (!prev || prev.result.inExperiment !== result.inExperiment || prev.result.variationId !== result.variationId) {
|
|
2790
|
+
this._assigned.set(key, {
|
|
2791
|
+
experiment,
|
|
2792
|
+
result
|
|
2793
|
+
});
|
|
2794
|
+
this._subscriptions.forEach(cb => {
|
|
2795
|
+
try {
|
|
2796
|
+
cb(experiment, result);
|
|
2797
|
+
} catch (e) {
|
|
2798
|
+
console.error(e);
|
|
2799
|
+
}
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
_recordChangedId(id) {
|
|
2804
|
+
this._completedChangeIds.add(id);
|
|
2805
|
+
}
|
|
2806
|
+
isOn(key) {
|
|
2807
|
+
return this.evalFeature(key).on;
|
|
2808
|
+
}
|
|
2809
|
+
isOff(key) {
|
|
2810
|
+
return this.evalFeature(key).off;
|
|
2811
|
+
}
|
|
2812
|
+
getFeatureValue(key, defaultValue) {
|
|
2813
|
+
const value = this.evalFeature(key).value;
|
|
2814
|
+
return value === null ? defaultValue : value;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
/**
|
|
2818
|
+
* @deprecated Use {@link evalFeature}
|
|
2819
|
+
* @param id
|
|
2820
|
+
*/
|
|
2821
|
+
// eslint-disable-next-line
|
|
2822
|
+
feature(id) {
|
|
2823
|
+
return this.evalFeature(id);
|
|
2824
|
+
}
|
|
2825
|
+
evalFeature(id) {
|
|
2826
|
+
return evalFeature(id, this._getEvalContext());
|
|
2827
|
+
}
|
|
2828
|
+
log(msg, ctx) {
|
|
2829
|
+
if (!this.debug) return;
|
|
2830
|
+
if (this._options.log) this._options.log(msg, ctx);else console.log(msg, ctx);
|
|
2831
|
+
}
|
|
2832
|
+
getDeferredTrackingCalls() {
|
|
2833
|
+
return Array.from(this._deferredTrackingCalls.values());
|
|
2834
|
+
}
|
|
2835
|
+
setDeferredTrackingCalls(calls) {
|
|
2836
|
+
this._deferredTrackingCalls = new Map(calls.filter(c => c && c.experiment && c.result).map(c => {
|
|
2837
|
+
return [getExperimentDedupeKey(c.experiment, c.result), c];
|
|
2838
|
+
}));
|
|
2839
|
+
}
|
|
2840
|
+
async fireDeferredTrackingCalls() {
|
|
2841
|
+
if (!this._options.trackingCallback) return;
|
|
2842
|
+
const promises = [];
|
|
2843
|
+
this._deferredTrackingCalls.forEach(call => {
|
|
2844
|
+
if (!call || !call.experiment || !call.result) {
|
|
2845
|
+
console.error("Invalid deferred tracking call", {
|
|
2846
|
+
call: call
|
|
2847
|
+
});
|
|
2848
|
+
} else {
|
|
2849
|
+
promises.push(this._options.trackingCallback(call.experiment, call.result));
|
|
2850
|
+
}
|
|
2851
|
+
});
|
|
2852
|
+
this._deferredTrackingCalls.clear();
|
|
2853
|
+
await Promise.all(promises);
|
|
2854
|
+
}
|
|
2855
|
+
setTrackingCallback(callback) {
|
|
2856
|
+
this._options.trackingCallback = callback;
|
|
2857
|
+
this.fireDeferredTrackingCalls();
|
|
2858
|
+
}
|
|
2859
|
+
setEventLogger(logger) {
|
|
2860
|
+
this._options.eventLogger = logger;
|
|
2861
|
+
}
|
|
2862
|
+
async logEvent(eventName, properties) {
|
|
2863
|
+
if (this._destroyed) {
|
|
2864
|
+
console.error("Cannot log event to destroyed GrowthBook instance");
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
if (this._options.enableDevMode) {
|
|
2868
|
+
this.logs.push({
|
|
2869
|
+
eventName,
|
|
2870
|
+
properties,
|
|
2871
|
+
timestamp: Date.now().toString(),
|
|
2872
|
+
logType: "event"
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
if (this._options.eventLogger) {
|
|
2876
|
+
try {
|
|
2877
|
+
await this._options.eventLogger(eventName, properties || {}, this._getUserContext());
|
|
2878
|
+
} catch (e) {
|
|
2879
|
+
console.error(e);
|
|
2880
|
+
}
|
|
2881
|
+
} else {
|
|
2882
|
+
console.error("No event logger configured");
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
_saveDeferredTrack(data) {
|
|
2886
|
+
this._deferredTrackingCalls.set(getExperimentDedupeKey(data.experiment, data.result), data);
|
|
2887
|
+
}
|
|
2888
|
+
_getContextUrl() {
|
|
2889
|
+
return this._options.url || (isBrowser ? window.location.href : "");
|
|
2890
|
+
}
|
|
2891
|
+
_isAutoExperimentBlockedByContext(experiment) {
|
|
2892
|
+
const changeType = getAutoExperimentChangeType(experiment);
|
|
2893
|
+
if (changeType === "visual") {
|
|
2894
|
+
if (this._options.disableVisualExperiments) return true;
|
|
2895
|
+
if (this._options.disableJsInjection) {
|
|
2896
|
+
if (experiment.variations.some(v => v.js)) {
|
|
2897
|
+
return true;
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
} else if (changeType === "redirect") {
|
|
2901
|
+
if (this._options.disableUrlRedirectExperiments) return true;
|
|
2902
|
+
|
|
2903
|
+
// Validate URLs
|
|
2904
|
+
try {
|
|
2905
|
+
const current = new URL(this._getContextUrl());
|
|
2906
|
+
for (const v of experiment.variations) {
|
|
2907
|
+
if (!v || !v.urlRedirect) continue;
|
|
2908
|
+
const url = new URL(v.urlRedirect);
|
|
2909
|
+
|
|
2910
|
+
// If we're blocking cross origin redirects, block if the protocol or host is different
|
|
2911
|
+
if (this._options.disableCrossOriginUrlRedirectExperiments) {
|
|
2912
|
+
if (url.protocol !== current.protocol) return true;
|
|
2913
|
+
if (url.host !== current.host) return true;
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
} catch (e) {
|
|
2917
|
+
// Problem parsing one of the URLs
|
|
2918
|
+
this.log("Error parsing current or redirect URL", {
|
|
2919
|
+
id: experiment.key,
|
|
2920
|
+
error: e
|
|
2921
|
+
});
|
|
2922
|
+
return true;
|
|
2923
|
+
}
|
|
2924
|
+
} else {
|
|
2925
|
+
// Block any unknown changeTypes
|
|
2926
|
+
return true;
|
|
2927
|
+
}
|
|
2928
|
+
if (experiment.changeId && (this._options.blockedChangeIds || []).includes(experiment.changeId)) {
|
|
2929
|
+
return true;
|
|
2930
|
+
}
|
|
2931
|
+
return false;
|
|
2932
|
+
}
|
|
2933
|
+
getRedirectUrl() {
|
|
2934
|
+
return this._redirectedUrl;
|
|
2935
|
+
}
|
|
2936
|
+
_getNavigateFunction() {
|
|
2937
|
+
if (this._options.navigate) {
|
|
2938
|
+
return {
|
|
2939
|
+
navigate: this._options.navigate,
|
|
2940
|
+
delay: 0
|
|
2941
|
+
};
|
|
2942
|
+
} else if (isBrowser) {
|
|
2943
|
+
return {
|
|
2944
|
+
navigate: url => {
|
|
2945
|
+
window.location.replace(url);
|
|
2946
|
+
},
|
|
2947
|
+
delay: 100
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
return {
|
|
2951
|
+
navigate: null,
|
|
2952
|
+
delay: 0
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
_applyDOMChanges(changes) {
|
|
2956
|
+
if (!isBrowser) return;
|
|
2957
|
+
const undo = [];
|
|
2958
|
+
if (changes.css) {
|
|
2959
|
+
const s = document.createElement("style");
|
|
2960
|
+
s.innerHTML = changes.css;
|
|
2961
|
+
document.head.appendChild(s);
|
|
2962
|
+
undo.push(() => s.remove());
|
|
2963
|
+
}
|
|
2964
|
+
if (changes.js) {
|
|
2965
|
+
const script = document.createElement("script");
|
|
2966
|
+
script.innerHTML = changes.js;
|
|
2967
|
+
if (this._options.jsInjectionNonce) {
|
|
2968
|
+
script.nonce = this._options.jsInjectionNonce;
|
|
2969
|
+
}
|
|
2970
|
+
document.head.appendChild(script);
|
|
2971
|
+
undo.push(() => script.remove());
|
|
2972
|
+
}
|
|
2973
|
+
if (changes.domMutations) {
|
|
2974
|
+
changes.domMutations.forEach(mutation => {
|
|
2975
|
+
undo.push(index.declarative(mutation).revert);
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
return () => {
|
|
2979
|
+
undo.forEach(fn => fn());
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
async refreshStickyBuckets(data) {
|
|
2983
|
+
if (this._options.stickyBucketService) {
|
|
2984
|
+
const ctx = this._getEvalContext();
|
|
2985
|
+
const docs = await getAllStickyBucketAssignmentDocs(ctx, this._options.stickyBucketService, data);
|
|
2986
|
+
this._options.stickyBucketAssignmentDocs = docs;
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
generateStickyBucketAssignmentDocsSync(stickyBucketService, payload) {
|
|
2990
|
+
if (!("getAllAssignmentsSync" in stickyBucketService)) {
|
|
2991
|
+
console.error("generating StickyBucketAssignmentDocs docs requires StickyBucketServiceSync");
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
const ctx = this._getEvalContext();
|
|
2995
|
+
const attributes = getStickyBucketAttributes(ctx, payload);
|
|
2996
|
+
return stickyBucketService.getAllAssignmentsSync(attributes);
|
|
2997
|
+
}
|
|
2998
|
+
inDevMode() {
|
|
2999
|
+
return !!this._options.enableDevMode;
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
async function prefetchPayload(options) {
|
|
3003
|
+
// Create a temporary instance, just to fetch the payload
|
|
3004
|
+
const instance = new GrowthBook(options);
|
|
3005
|
+
await refreshFeatures({
|
|
3006
|
+
instance,
|
|
3007
|
+
skipCache: options.skipCache,
|
|
3008
|
+
allowStale: false,
|
|
3009
|
+
backgroundSync: options.streaming
|
|
3010
|
+
});
|
|
3011
|
+
instance.destroy();
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
const SDK_VERSION = loadSDKVersion();
|
|
3015
|
+
class GrowthBookClient {
|
|
3016
|
+
// Properties and methods that start with "_" are mangled by Terser (saves ~150 bytes)
|
|
3017
|
+
|
|
3018
|
+
constructor(options) {
|
|
3019
|
+
options = options || {};
|
|
3020
|
+
// These properties are all initialized in the constructor instead of above
|
|
3021
|
+
// This saves ~80 bytes in the final output
|
|
3022
|
+
this.version = SDK_VERSION;
|
|
3023
|
+
this._options = options;
|
|
3024
|
+
this.debug = !!options.debug;
|
|
3025
|
+
this.ready = false;
|
|
3026
|
+
this._features = {};
|
|
3027
|
+
this._experiments = [];
|
|
3028
|
+
this.log = this.log.bind(this);
|
|
3029
|
+
if (options.plugins) {
|
|
3030
|
+
for (const plugin of options.plugins) {
|
|
3031
|
+
plugin(this);
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
async setPayload(payload) {
|
|
3036
|
+
this._payload = payload;
|
|
3037
|
+
const data = await decryptPayload(payload, this._options.decryptionKey);
|
|
3038
|
+
this._decryptedPayload = data;
|
|
3039
|
+
if (data.features) {
|
|
3040
|
+
this._features = data.features;
|
|
3041
|
+
}
|
|
3042
|
+
if (data.experiments) {
|
|
3043
|
+
this._experiments = data.experiments;
|
|
3044
|
+
}
|
|
3045
|
+
if (data.savedGroups) {
|
|
3046
|
+
this._options.savedGroups = data.savedGroups;
|
|
3047
|
+
}
|
|
3048
|
+
this.ready = true;
|
|
3049
|
+
}
|
|
3050
|
+
initSync(options) {
|
|
3051
|
+
const payload = options.payload;
|
|
3052
|
+
if (payload.encryptedExperiments || payload.encryptedFeatures) {
|
|
3053
|
+
throw new Error("initSync does not support encrypted payloads");
|
|
3054
|
+
}
|
|
3055
|
+
this._payload = payload;
|
|
3056
|
+
this._decryptedPayload = payload;
|
|
3057
|
+
if (payload.features) {
|
|
3058
|
+
this._features = payload.features;
|
|
3059
|
+
}
|
|
3060
|
+
if (payload.experiments) {
|
|
3061
|
+
this._experiments = payload.experiments;
|
|
3062
|
+
}
|
|
3063
|
+
this.ready = true;
|
|
3064
|
+
startStreaming(this, options);
|
|
3065
|
+
return this;
|
|
3066
|
+
}
|
|
3067
|
+
async init(options) {
|
|
3068
|
+
options = options || {};
|
|
3069
|
+
if (options.cacheSettings) {
|
|
3070
|
+
configureCache(options.cacheSettings);
|
|
3071
|
+
}
|
|
3072
|
+
if (options.payload) {
|
|
3073
|
+
await this.setPayload(options.payload);
|
|
3074
|
+
startStreaming(this, options);
|
|
3075
|
+
return {
|
|
3076
|
+
success: true,
|
|
3077
|
+
source: "init"
|
|
3078
|
+
};
|
|
3079
|
+
} else {
|
|
3080
|
+
const {
|
|
3081
|
+
data,
|
|
3082
|
+
...res
|
|
3083
|
+
} = await this._refresh({
|
|
3084
|
+
...options,
|
|
3085
|
+
allowStale: true
|
|
3086
|
+
});
|
|
3087
|
+
startStreaming(this, options);
|
|
3088
|
+
await this.setPayload(data || {});
|
|
3089
|
+
return res;
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
async refreshFeatures(options) {
|
|
3093
|
+
const res = await this._refresh({
|
|
3094
|
+
...(options || {}),
|
|
3095
|
+
allowStale: false
|
|
3096
|
+
});
|
|
3097
|
+
if (res.data) {
|
|
3098
|
+
await this.setPayload(res.data);
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
getApiInfo() {
|
|
3102
|
+
return [this.getApiHosts().apiHost, this.getClientKey()];
|
|
3103
|
+
}
|
|
3104
|
+
getApiHosts() {
|
|
3105
|
+
return getApiHosts(this._options);
|
|
3106
|
+
}
|
|
3107
|
+
getClientKey() {
|
|
3108
|
+
return this._options.clientKey || "";
|
|
3109
|
+
}
|
|
3110
|
+
getPayload() {
|
|
3111
|
+
return this._payload || {
|
|
3112
|
+
features: this.getFeatures(),
|
|
3113
|
+
experiments: this._experiments || []
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
getDecryptedPayload() {
|
|
3117
|
+
return this._decryptedPayload || this.getPayload();
|
|
3118
|
+
}
|
|
3119
|
+
async _refresh(_ref) {
|
|
3120
|
+
let {
|
|
3121
|
+
timeout,
|
|
3122
|
+
skipCache,
|
|
3123
|
+
allowStale,
|
|
3124
|
+
streaming
|
|
3125
|
+
} = _ref;
|
|
3126
|
+
if (!this._options.clientKey) {
|
|
3127
|
+
throw new Error("Missing clientKey");
|
|
3128
|
+
}
|
|
3129
|
+
// Trigger refresh in feature repository
|
|
3130
|
+
return refreshFeatures({
|
|
3131
|
+
instance: this,
|
|
3132
|
+
timeout,
|
|
3133
|
+
skipCache: skipCache || this._options.disableCache,
|
|
3134
|
+
allowStale,
|
|
3135
|
+
backgroundSync: streaming ?? true
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
getFeatures() {
|
|
3139
|
+
return this._features || {};
|
|
3140
|
+
}
|
|
3141
|
+
getGlobalAttributes() {
|
|
3142
|
+
return this._options.globalAttributes || {};
|
|
3143
|
+
}
|
|
3144
|
+
setGlobalAttributes(attributes) {
|
|
3145
|
+
this._options.globalAttributes = attributes;
|
|
3146
|
+
}
|
|
3147
|
+
destroy() {
|
|
3148
|
+
this._destroyed = true;
|
|
3149
|
+
unsubscribe(this);
|
|
3150
|
+
|
|
3151
|
+
// Release references to save memory
|
|
3152
|
+
this._features = {};
|
|
3153
|
+
this._experiments = [];
|
|
3154
|
+
this._decryptedPayload = undefined;
|
|
3155
|
+
this._payload = undefined;
|
|
3156
|
+
this._options = {};
|
|
3157
|
+
}
|
|
3158
|
+
isDestroyed() {
|
|
3159
|
+
return !!this._destroyed;
|
|
3160
|
+
}
|
|
3161
|
+
setEventLogger(logger) {
|
|
3162
|
+
this._options.eventLogger = logger;
|
|
3163
|
+
}
|
|
3164
|
+
logEvent(eventName, properties, userContext) {
|
|
3165
|
+
if (this._options.eventLogger) {
|
|
3166
|
+
const ctx = this._getEvalContext(userContext);
|
|
3167
|
+
this._options.eventLogger(eventName, properties, ctx.user);
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
runInlineExperiment(experiment, userContext) {
|
|
3171
|
+
const {
|
|
3172
|
+
result
|
|
3173
|
+
} = runExperiment(experiment, null, this._getEvalContext(userContext));
|
|
3174
|
+
return result;
|
|
3175
|
+
}
|
|
3176
|
+
_getEvalContext(userContext) {
|
|
3177
|
+
if (this._options.globalAttributes) {
|
|
3178
|
+
userContext = {
|
|
3179
|
+
...userContext,
|
|
3180
|
+
attributes: {
|
|
3181
|
+
...this._options.globalAttributes,
|
|
3182
|
+
...userContext.attributes
|
|
3183
|
+
}
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
return {
|
|
3187
|
+
user: userContext,
|
|
3188
|
+
global: this._getGlobalContext(),
|
|
3189
|
+
stack: {
|
|
3190
|
+
evaluatedFeatures: new Set()
|
|
3191
|
+
}
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
_getGlobalContext() {
|
|
3195
|
+
return {
|
|
3196
|
+
features: this._features,
|
|
3197
|
+
experiments: this._experiments,
|
|
3198
|
+
log: this.log,
|
|
3199
|
+
enabled: this._options.enabled,
|
|
3200
|
+
qaMode: this._options.qaMode,
|
|
3201
|
+
savedGroups: this._options.savedGroups,
|
|
3202
|
+
forcedFeatureValues: this._options.forcedFeatureValues,
|
|
3203
|
+
forcedVariations: this._options.forcedVariations,
|
|
3204
|
+
trackingCallback: this._options.trackingCallback,
|
|
3205
|
+
onFeatureUsage: this._options.onFeatureUsage
|
|
3206
|
+
};
|
|
3207
|
+
}
|
|
3208
|
+
isOn(key, userContext) {
|
|
3209
|
+
return this.evalFeature(key, userContext).on;
|
|
3210
|
+
}
|
|
3211
|
+
isOff(key, userContext) {
|
|
3212
|
+
return this.evalFeature(key, userContext).off;
|
|
3213
|
+
}
|
|
3214
|
+
getFeatureValue(key, defaultValue, userContext) {
|
|
3215
|
+
const value = this.evalFeature(key, userContext).value;
|
|
3216
|
+
return value === null ? defaultValue : value;
|
|
3217
|
+
}
|
|
3218
|
+
evalFeature(id, userContext) {
|
|
3219
|
+
return evalFeature(id, this._getEvalContext(userContext));
|
|
3220
|
+
}
|
|
3221
|
+
log(msg, ctx) {
|
|
3222
|
+
if (!this.debug) return;
|
|
3223
|
+
if (this._options.log) this._options.log(msg, ctx);else console.log(msg, ctx);
|
|
3224
|
+
}
|
|
3225
|
+
setTrackingCallback(callback) {
|
|
3226
|
+
this._options.trackingCallback = callback;
|
|
3227
|
+
}
|
|
3228
|
+
async applyStickyBuckets(partialContext, stickyBucketService) {
|
|
3229
|
+
const ctx = this._getEvalContext(partialContext);
|
|
3230
|
+
const stickyBucketAssignmentDocs = await getAllStickyBucketAssignmentDocs(ctx, stickyBucketService);
|
|
3231
|
+
return {
|
|
3232
|
+
...partialContext,
|
|
3233
|
+
stickyBucketAssignmentDocs,
|
|
3234
|
+
saveStickyBucketAssignmentDoc: doc => stickyBucketService.saveAssignments(doc)
|
|
3235
|
+
};
|
|
3236
|
+
}
|
|
3237
|
+
createScopedInstance(userContext, userPlugins) {
|
|
3238
|
+
return new UserScopedGrowthBook(this, userContext, [...(this._options.plugins || []), ...(userPlugins || [])]);
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
class UserScopedGrowthBook {
|
|
3242
|
+
constructor(gb, userContext, plugins) {
|
|
3243
|
+
this._gb = gb;
|
|
3244
|
+
this._userContext = userContext;
|
|
3245
|
+
this.logs = [];
|
|
3246
|
+
this._userContext.trackedExperiments = this._userContext.trackedExperiments || new Set();
|
|
3247
|
+
this._userContext.trackedFeatureUsage = this._userContext.trackedFeatureUsage || {};
|
|
3248
|
+
this._userContext.devLogs = this.logs;
|
|
3249
|
+
if (plugins) {
|
|
3250
|
+
for (const plugin of plugins) {
|
|
3251
|
+
plugin(this);
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
runInlineExperiment(experiment) {
|
|
3256
|
+
return this._gb.runInlineExperiment(experiment, this._userContext);
|
|
3257
|
+
}
|
|
3258
|
+
isOn(key) {
|
|
3259
|
+
return this._gb.isOn(key, this._userContext);
|
|
3260
|
+
}
|
|
3261
|
+
isOff(key) {
|
|
3262
|
+
return this._gb.isOff(key, this._userContext);
|
|
3263
|
+
}
|
|
3264
|
+
getFeatureValue(key, defaultValue) {
|
|
3265
|
+
return this._gb.getFeatureValue(key, defaultValue, this._userContext);
|
|
3266
|
+
}
|
|
3267
|
+
evalFeature(id) {
|
|
3268
|
+
return this._gb.evalFeature(id, this._userContext);
|
|
3269
|
+
}
|
|
3270
|
+
logEvent(eventName, properties) {
|
|
3271
|
+
if (this._userContext.enableDevMode) {
|
|
3272
|
+
this.logs.push({
|
|
3273
|
+
eventName,
|
|
3274
|
+
properties,
|
|
3275
|
+
timestamp: Date.now().toString(),
|
|
3276
|
+
logType: "event"
|
|
3277
|
+
});
|
|
3278
|
+
}
|
|
3279
|
+
this._gb.logEvent(eventName, properties || {}, this._userContext);
|
|
3280
|
+
}
|
|
3281
|
+
setTrackingCallback(cb) {
|
|
3282
|
+
this._userContext.trackingCallback = cb;
|
|
3283
|
+
}
|
|
3284
|
+
getApiInfo() {
|
|
3285
|
+
return this._gb.getApiInfo();
|
|
3286
|
+
}
|
|
3287
|
+
getClientKey() {
|
|
3288
|
+
return this._gb.getClientKey();
|
|
3289
|
+
}
|
|
3290
|
+
setURL(url) {
|
|
3291
|
+
this._userContext.url = url;
|
|
3292
|
+
}
|
|
3293
|
+
updateAttributes(attributes) {
|
|
3294
|
+
this._userContext.attributes = {
|
|
3295
|
+
...this._userContext.attributes,
|
|
3296
|
+
...attributes
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
setAttributeOverrides(overrides) {
|
|
3300
|
+
this._userContext.attributeOverrides = overrides;
|
|
3301
|
+
}
|
|
3302
|
+
async setForcedVariations(vars) {
|
|
3303
|
+
this._userContext.forcedVariations = vars || {};
|
|
3304
|
+
}
|
|
3305
|
+
// eslint-disable-next-line
|
|
3306
|
+
setForcedFeatures(map) {
|
|
3307
|
+
this._userContext.forcedFeatureValues = map;
|
|
3308
|
+
}
|
|
3309
|
+
getUserContext() {
|
|
3310
|
+
return this._userContext;
|
|
3311
|
+
}
|
|
3312
|
+
getVersion() {
|
|
3313
|
+
return SDK_VERSION;
|
|
3314
|
+
}
|
|
3315
|
+
getDecryptedPayload() {
|
|
3316
|
+
return this._gb.getDecryptedPayload();
|
|
3317
|
+
}
|
|
3318
|
+
inDevMode() {
|
|
3319
|
+
return !!this._userContext.enableDevMode;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
/**
|
|
3324
|
+
* Responsible for reading and writing documents which describe sticky bucket assignments.
|
|
3325
|
+
*/
|
|
3326
|
+
class StickyBucketService {
|
|
3327
|
+
constructor(opts) {
|
|
3328
|
+
opts = opts || {};
|
|
3329
|
+
this.prefix = opts.prefix || "";
|
|
3330
|
+
}
|
|
3331
|
+
/**
|
|
3332
|
+
* The SDK calls getAllAssignments to populate sticky buckets. This in turn will
|
|
3333
|
+
* typically loop through individual getAssignments calls. However, some StickyBucketService
|
|
3334
|
+
* instances (i.e. Redis) will instead perform a multi-query inside getAllAssignments instead.
|
|
3335
|
+
*/
|
|
3336
|
+
async getAllAssignments(attributes) {
|
|
3337
|
+
const docs = {};
|
|
3338
|
+
(await Promise.all(Object.entries(attributes).map(_ref => {
|
|
3339
|
+
let [attributeName, attributeValue] = _ref;
|
|
3340
|
+
return this.getAssignments(attributeName, attributeValue);
|
|
3341
|
+
}))).forEach(doc => {
|
|
3342
|
+
if (doc) {
|
|
3343
|
+
const key = getStickyBucketAttributeKey(doc.attributeName, doc.attributeValue);
|
|
3344
|
+
docs[key] = doc;
|
|
3345
|
+
}
|
|
3346
|
+
});
|
|
3347
|
+
return docs;
|
|
3348
|
+
}
|
|
3349
|
+
getKey(attributeName, attributeValue) {
|
|
3350
|
+
return `${this.prefix}${attributeName}||${attributeValue}`;
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
class StickyBucketServiceSync extends StickyBucketService {
|
|
3354
|
+
async getAssignments(attributeName, attributeValue) {
|
|
3355
|
+
return this.getAssignmentsSync(attributeName, attributeValue);
|
|
3356
|
+
}
|
|
3357
|
+
async saveAssignments(doc) {
|
|
3358
|
+
this.saveAssignmentsSync(doc);
|
|
3359
|
+
}
|
|
3360
|
+
getAllAssignmentsSync(attributes) {
|
|
3361
|
+
const docs = {};
|
|
3362
|
+
Object.entries(attributes).map(_ref2 => {
|
|
3363
|
+
let [attributeName, attributeValue] = _ref2;
|
|
3364
|
+
return this.getAssignmentsSync(attributeName, attributeValue);
|
|
3365
|
+
}).forEach(doc => {
|
|
3366
|
+
if (doc) {
|
|
3367
|
+
const key = getStickyBucketAttributeKey(doc.attributeName, doc.attributeValue);
|
|
3368
|
+
docs[key] = doc;
|
|
3369
|
+
}
|
|
3370
|
+
});
|
|
3371
|
+
return docs;
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
class LocalStorageStickyBucketService extends StickyBucketService {
|
|
3375
|
+
constructor(opts) {
|
|
3376
|
+
opts = opts || {};
|
|
3377
|
+
super();
|
|
3378
|
+
this.prefix = opts.prefix || "gbStickyBuckets__";
|
|
3379
|
+
try {
|
|
3380
|
+
this.localStorage = opts.localStorage || globalThis.localStorage;
|
|
3381
|
+
} catch (e) {
|
|
3382
|
+
// Ignore localStorage errors
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
async getAssignments(attributeName, attributeValue) {
|
|
3386
|
+
const key = this.getKey(attributeName, attributeValue);
|
|
3387
|
+
let doc = null;
|
|
3388
|
+
if (!this.localStorage) return doc;
|
|
3389
|
+
try {
|
|
3390
|
+
const raw = (await this.localStorage.getItem(key)) || "{}";
|
|
3391
|
+
const data = JSON.parse(raw);
|
|
3392
|
+
if (data.attributeName && data.attributeValue && data.assignments) {
|
|
3393
|
+
doc = data;
|
|
3394
|
+
}
|
|
3395
|
+
} catch (e) {
|
|
3396
|
+
// Ignore localStorage errors
|
|
3397
|
+
}
|
|
3398
|
+
return doc;
|
|
3399
|
+
}
|
|
3400
|
+
async saveAssignments(doc) {
|
|
3401
|
+
const key = this.getKey(doc.attributeName, doc.attributeValue);
|
|
3402
|
+
if (!this.localStorage) return;
|
|
3403
|
+
try {
|
|
3404
|
+
await this.localStorage.setItem(key, JSON.stringify(doc));
|
|
3405
|
+
} catch (e) {
|
|
3406
|
+
// Ignore localStorage errors
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
class ExpressCookieStickyBucketService extends StickyBucketServiceSync {
|
|
3411
|
+
/**
|
|
3412
|
+
* Intended to be used with cookieParser() middleware from npm: 'cookie-parser'.
|
|
3413
|
+
* Assumes:
|
|
3414
|
+
* - reading a cookie is automatically decoded via decodeURIComponent() or similar
|
|
3415
|
+
* - writing a cookie name & value must be manually encoded via encodeURIComponent() or similar
|
|
3416
|
+
* - all cookie bodies are JSON encoded strings and are manually encoded/decoded
|
|
3417
|
+
*/
|
|
3418
|
+
|
|
3419
|
+
constructor(_ref3) {
|
|
3420
|
+
let {
|
|
3421
|
+
prefix = "gbStickyBuckets__",
|
|
3422
|
+
req,
|
|
3423
|
+
res,
|
|
3424
|
+
cookieAttributes = {
|
|
3425
|
+
maxAge: 180 * 24 * 3600 * 1000
|
|
3426
|
+
} // 180 days
|
|
3427
|
+
} = _ref3;
|
|
3428
|
+
super();
|
|
3429
|
+
this.prefix = prefix;
|
|
3430
|
+
this.req = req;
|
|
3431
|
+
this.res = res;
|
|
3432
|
+
this.cookieAttributes = cookieAttributes;
|
|
3433
|
+
}
|
|
3434
|
+
getAssignmentsSync(attributeName, attributeValue) {
|
|
3435
|
+
const key = this.getKey(attributeName, attributeValue);
|
|
3436
|
+
let doc = null;
|
|
3437
|
+
if (!this.req) return doc;
|
|
3438
|
+
try {
|
|
3439
|
+
const raw = this.req.cookies[key] || "{}";
|
|
3440
|
+
const data = JSON.parse(raw);
|
|
3441
|
+
if (data.attributeName && data.attributeValue && data.assignments) {
|
|
3442
|
+
doc = data;
|
|
3443
|
+
}
|
|
3444
|
+
} catch (e) {
|
|
3445
|
+
// Ignore cookie errors
|
|
3446
|
+
}
|
|
3447
|
+
return doc;
|
|
3448
|
+
}
|
|
3449
|
+
saveAssignmentsSync(doc) {
|
|
3450
|
+
const key = this.getKey(doc.attributeName, doc.attributeValue);
|
|
3451
|
+
if (!this.res) return;
|
|
3452
|
+
const str = JSON.stringify(doc);
|
|
3453
|
+
this.res.cookie(encodeURIComponent(key), encodeURIComponent(str), this.cookieAttributes);
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
class BrowserCookieStickyBucketService extends StickyBucketServiceSync {
|
|
3457
|
+
/**
|
|
3458
|
+
* Intended to be used with npm: 'js-cookie'.
|
|
3459
|
+
* Assumes:
|
|
3460
|
+
* - reading a cookie is automatically decoded via decodeURIComponent() or similar
|
|
3461
|
+
* - writing a cookie name & value is automatically encoded via encodeURIComponent() or similar
|
|
3462
|
+
* - all cookie bodies are JSON encoded strings and are manually encoded/decoded
|
|
3463
|
+
*/
|
|
3464
|
+
|
|
3465
|
+
constructor(_ref4) {
|
|
3466
|
+
let {
|
|
3467
|
+
prefix = "gbStickyBuckets__",
|
|
3468
|
+
jsCookie,
|
|
3469
|
+
cookieAttributes = {
|
|
3470
|
+
expires: 180
|
|
3471
|
+
} // 180 days
|
|
3472
|
+
} = _ref4;
|
|
3473
|
+
super();
|
|
3474
|
+
this.prefix = prefix;
|
|
3475
|
+
this.jsCookie = jsCookie;
|
|
3476
|
+
this.cookieAttributes = cookieAttributes;
|
|
3477
|
+
}
|
|
3478
|
+
getAssignmentsSync(attributeName, attributeValue) {
|
|
3479
|
+
const key = this.getKey(attributeName, attributeValue);
|
|
3480
|
+
let doc = null;
|
|
3481
|
+
if (!this.jsCookie) return doc;
|
|
3482
|
+
try {
|
|
3483
|
+
const raw = this.jsCookie.get(key);
|
|
3484
|
+
const data = JSON.parse(raw || "{}");
|
|
3485
|
+
if (data.attributeName && data.attributeValue && data.assignments) {
|
|
3486
|
+
doc = data;
|
|
3487
|
+
}
|
|
3488
|
+
} catch (e) {
|
|
3489
|
+
// Ignore cookie errors
|
|
3490
|
+
}
|
|
3491
|
+
return doc;
|
|
3492
|
+
}
|
|
3493
|
+
async saveAssignmentsSync(doc) {
|
|
3494
|
+
const key = this.getKey(doc.attributeName, doc.attributeValue);
|
|
3495
|
+
if (!this.jsCookie) return;
|
|
3496
|
+
const str = JSON.stringify(doc);
|
|
3497
|
+
this.jsCookie.set(key, str, this.cookieAttributes);
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
class RedisStickyBucketService extends StickyBucketService {
|
|
3501
|
+
/** Intended to be used with npm: 'ioredis'. **/
|
|
3502
|
+
|
|
3503
|
+
constructor(_ref5) {
|
|
3504
|
+
let {
|
|
3505
|
+
redis
|
|
3506
|
+
} = _ref5;
|
|
3507
|
+
super();
|
|
3508
|
+
this.redis = redis;
|
|
3509
|
+
}
|
|
3510
|
+
async getAllAssignments(attributes) {
|
|
3511
|
+
const docs = {};
|
|
3512
|
+
const keys = Object.entries(attributes).map(_ref6 => {
|
|
3513
|
+
let [attributeName, attributeValue] = _ref6;
|
|
3514
|
+
return getStickyBucketAttributeKey(attributeName, attributeValue);
|
|
3515
|
+
});
|
|
3516
|
+
if (!this.redis) return docs;
|
|
3517
|
+
await this.redis.mget(...keys).then(values => {
|
|
3518
|
+
values.forEach(raw => {
|
|
3519
|
+
try {
|
|
3520
|
+
const data = JSON.parse(raw || "{}");
|
|
3521
|
+
if (data.attributeName && "attributeValue" in data && data.assignments) {
|
|
3522
|
+
const key = getStickyBucketAttributeKey(data.attributeName, toString(data.attributeValue));
|
|
3523
|
+
docs[key] = data;
|
|
3524
|
+
}
|
|
3525
|
+
} catch (e) {
|
|
3526
|
+
// ignore redis doc parse errors
|
|
3527
|
+
}
|
|
3528
|
+
});
|
|
3529
|
+
});
|
|
3530
|
+
return docs;
|
|
3531
|
+
}
|
|
3532
|
+
async getAssignments(_attributeName, _attributeValue) {
|
|
3533
|
+
// not implemented
|
|
3534
|
+
return null;
|
|
3535
|
+
}
|
|
3536
|
+
async saveAssignments(doc) {
|
|
3537
|
+
const key = this.getKey(doc.attributeName, doc.attributeValue);
|
|
3538
|
+
if (!this.redis) return;
|
|
3539
|
+
await this.redis.set(key, JSON.stringify(doc));
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
exports.BrowserCookieStickyBucketService = BrowserCookieStickyBucketService;
|
|
3544
|
+
exports.EVENT_EXPERIMENT_VIEWED = EVENT_EXPERIMENT_VIEWED;
|
|
3545
|
+
exports.EVENT_FEATURE_EVALUATED = EVENT_FEATURE_EVALUATED;
|
|
3546
|
+
exports.ExpressCookieStickyBucketService = ExpressCookieStickyBucketService;
|
|
3547
|
+
exports.GrowthBook = GrowthBook;
|
|
3548
|
+
exports.GrowthBookClient = GrowthBookClient;
|
|
3549
|
+
exports.GrowthBookMultiUser = GrowthBookClient;
|
|
3550
|
+
exports.LocalStorageStickyBucketService = LocalStorageStickyBucketService;
|
|
3551
|
+
exports.RedisStickyBucketService = RedisStickyBucketService;
|
|
3552
|
+
exports.StickyBucketService = StickyBucketService;
|
|
3553
|
+
exports.StickyBucketServiceSync = StickyBucketServiceSync;
|
|
3554
|
+
exports.UserScopedGrowthBook = UserScopedGrowthBook;
|
|
3555
|
+
exports.clearCache = clearCache;
|
|
3556
|
+
exports.configureCache = configureCache;
|
|
3557
|
+
exports.evalCondition = evalCondition;
|
|
3558
|
+
exports.getAutoExperimentChangeType = getAutoExperimentChangeType;
|
|
3559
|
+
exports.getPolyfills = getPolyfills;
|
|
3560
|
+
exports.helpers = helpers;
|
|
3561
|
+
exports.isURLTargeted = isURLTargeted;
|
|
3562
|
+
exports.onHidden = onHidden;
|
|
3563
|
+
exports.onVisible = onVisible;
|
|
3564
|
+
exports.paddedVersionString = paddedVersionString;
|
|
3565
|
+
exports.prefetchPayload = prefetchPayload;
|
|
3566
|
+
exports.setPolyfills = setPolyfills;
|
|
3567
|
+
|
|
3568
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
3569
|
+
|
|
3570
|
+
return exports;
|
|
3571
|
+
|
|
3572
|
+
})({});
|
|
3573
|
+
//# sourceMappingURL=index.js.map
|
|
3574
|
+
|