@variantlab/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/_size/config.cjs +634 -0
- package/dist/_size/config.cjs.map +1 -0
- package/dist/_size/config.d.cts +2 -0
- package/dist/_size/config.d.ts +2 -0
- package/dist/_size/config.js +631 -0
- package/dist/_size/config.js.map +1 -0
- package/dist/_size/engine.cjs +1178 -0
- package/dist/_size/engine.cjs.map +1 -0
- package/dist/_size/engine.d.cts +2 -0
- package/dist/_size/engine.d.ts +2 -0
- package/dist/_size/engine.js +1175 -0
- package/dist/_size/engine.js.map +1 -0
- package/dist/_size/targeting.cjs +332 -0
- package/dist/_size/targeting.cjs.map +1 -0
- package/dist/_size/targeting.d.cts +97 -0
- package/dist/_size/targeting.d.ts +97 -0
- package/dist/_size/targeting.js +325 -0
- package/dist/_size/targeting.js.map +1 -0
- package/dist/config-B3DTOt1J.d.ts +46 -0
- package/dist/config-U0cqXPTa.d.cts +46 -0
- package/dist/engine-BEuGiH3G.d.cts +173 -0
- package/dist/engine-BoNBfZBL.d.ts +173 -0
- package/dist/index.cjs +1352 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +223 -0
- package/dist/index.d.ts +223 -0
- package/dist/index.js +1324 -0
- package/dist/index.js.map +1 -0
- package/dist/types-BkXPpEyg.d.cts +82 -0
- package/dist/types-BkXPpEyg.d.ts +82 -0
- package/package.json +58 -0
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/engine/crash-counter.ts
|
|
4
|
+
var CrashCounter = class {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.crashes = /* @__PURE__ */ new Map();
|
|
7
|
+
}
|
|
8
|
+
/** Record a crash; returns the new in-window count (post-prune). */
|
|
9
|
+
record(experimentId, now, window) {
|
|
10
|
+
const list = this.crashes.get(experimentId) ?? [];
|
|
11
|
+
list.push(now);
|
|
12
|
+
this.crashes.set(experimentId, list);
|
|
13
|
+
return this.countWithin(experimentId, now, window);
|
|
14
|
+
}
|
|
15
|
+
countWithin(experimentId, now, window) {
|
|
16
|
+
const list = this.crashes.get(experimentId);
|
|
17
|
+
if (list === void 0) return 0;
|
|
18
|
+
const cutoff = now - window;
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < list.length && (list[i] ?? 0) < cutoff) i++;
|
|
21
|
+
if (i > 0) list.splice(0, i);
|
|
22
|
+
return list.length;
|
|
23
|
+
}
|
|
24
|
+
clear(experimentId) {
|
|
25
|
+
if (experimentId === void 0) this.crashes.clear();
|
|
26
|
+
else this.crashes.delete(experimentId);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// src/targeting/glob.ts
|
|
31
|
+
function compileGlob(pattern) {
|
|
32
|
+
if (pattern.length === 0) return null;
|
|
33
|
+
if (pattern === "*") return [{ kind: "param" }];
|
|
34
|
+
if (pattern === "**") return [{ kind: "rest" }];
|
|
35
|
+
if (pattern[0] !== "/") return null;
|
|
36
|
+
if (pattern.indexOf("***") >= 0) return null;
|
|
37
|
+
const normalized = pattern.length > 1 && pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
|
|
38
|
+
if (normalized === "/") return [];
|
|
39
|
+
const raw = normalized.slice(1).split("/");
|
|
40
|
+
const segs = [];
|
|
41
|
+
for (let i = 0; i < raw.length; i++) {
|
|
42
|
+
const part = raw[i];
|
|
43
|
+
if (part.length === 0) return null;
|
|
44
|
+
if (part === "**") {
|
|
45
|
+
if (i !== raw.length - 1) return null;
|
|
46
|
+
segs.push({ kind: "rest" });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (part === "*") {
|
|
50
|
+
segs.push({ kind: "param" });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (part[0] === ":") {
|
|
54
|
+
if (part.length < 2) return null;
|
|
55
|
+
segs.push({ kind: "param" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (/[*:?[\]{}!]/.test(part)) return null;
|
|
59
|
+
segs.push({ kind: "literal", value: part });
|
|
60
|
+
}
|
|
61
|
+
return segs;
|
|
62
|
+
}
|
|
63
|
+
function matchCompiledRoute(segs, path) {
|
|
64
|
+
if (path.length === 0) return false;
|
|
65
|
+
if (path[0] !== "/") return false;
|
|
66
|
+
const normalized = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
67
|
+
if (segs.length === 0) return normalized === "/";
|
|
68
|
+
const parts = normalized === "/" ? [] : normalized.slice(1).split("/");
|
|
69
|
+
for (let i = 0; i < segs.length; i++) {
|
|
70
|
+
const seg = segs[i];
|
|
71
|
+
if (seg.kind === "rest") {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (i >= parts.length) return false;
|
|
75
|
+
const part = parts[i];
|
|
76
|
+
if (part.length === 0) return false;
|
|
77
|
+
if (seg.kind === "literal") {
|
|
78
|
+
if (seg.value !== part) return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return parts.length === segs.length;
|
|
82
|
+
}
|
|
83
|
+
function matchRoute(pattern, path) {
|
|
84
|
+
const segs = compileGlob(pattern);
|
|
85
|
+
if (segs === null) return false;
|
|
86
|
+
return matchCompiledRoute(segs, path);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/targeting/semver.ts
|
|
90
|
+
function parseVersion(s) {
|
|
91
|
+
if (s.length === 0) return null;
|
|
92
|
+
const parts = [0, 0, 0];
|
|
93
|
+
let idx = 0;
|
|
94
|
+
let part = 0;
|
|
95
|
+
let seen = false;
|
|
96
|
+
for (let i = 0; i < s.length; i++) {
|
|
97
|
+
const c = s.charCodeAt(i);
|
|
98
|
+
if (c === 46) {
|
|
99
|
+
if (!seen) return null;
|
|
100
|
+
parts[idx++] = part;
|
|
101
|
+
if (idx > 2) return null;
|
|
102
|
+
part = 0;
|
|
103
|
+
seen = false;
|
|
104
|
+
} else if (c >= 48 && c <= 57) {
|
|
105
|
+
part = part * 10 + (c - 48);
|
|
106
|
+
seen = true;
|
|
107
|
+
} else return null;
|
|
108
|
+
}
|
|
109
|
+
if (!seen || idx !== 2) return null;
|
|
110
|
+
parts[2] = part;
|
|
111
|
+
return [parts[0], parts[1], parts[2]];
|
|
112
|
+
}
|
|
113
|
+
function cmpVersion(a, b) {
|
|
114
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
115
|
+
}
|
|
116
|
+
function parseSemver(s) {
|
|
117
|
+
if (s.length === 0) return null;
|
|
118
|
+
const clauses = [];
|
|
119
|
+
for (const part of s.split("||")) {
|
|
120
|
+
const c = parseClause(part.trim());
|
|
121
|
+
if (c === null) return null;
|
|
122
|
+
clauses.push(c);
|
|
123
|
+
}
|
|
124
|
+
return clauses;
|
|
125
|
+
}
|
|
126
|
+
function parseClause(s) {
|
|
127
|
+
if (s.length === 0) return null;
|
|
128
|
+
const hy = s.indexOf(" - ");
|
|
129
|
+
if (hy >= 0) {
|
|
130
|
+
const lo = parseVersion(s.slice(0, hy).trim());
|
|
131
|
+
const hi = parseVersion(s.slice(hy + 3).trim());
|
|
132
|
+
if (lo === null || hi === null) return null;
|
|
133
|
+
return [
|
|
134
|
+
{ op: ">=", v: lo },
|
|
135
|
+
{ op: "<=", v: hi }
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
const cmps = [];
|
|
139
|
+
for (const tok of s.split(/\s+/)) {
|
|
140
|
+
if (tok.length === 0) continue;
|
|
141
|
+
const parsed = parseComparator(tok);
|
|
142
|
+
if (parsed === null) return null;
|
|
143
|
+
for (const c of parsed) cmps.push(c);
|
|
144
|
+
}
|
|
145
|
+
return cmps.length > 0 ? cmps : null;
|
|
146
|
+
}
|
|
147
|
+
function parseComparator(s) {
|
|
148
|
+
const c0 = s[0];
|
|
149
|
+
if (c0 === "^" || c0 === "~") {
|
|
150
|
+
const v2 = parseVersion(s.slice(1));
|
|
151
|
+
if (v2 === null) return null;
|
|
152
|
+
const upper = c0 === "^" ? [v2[0] + 1, 0, 0] : [v2[0], v2[1] + 1, 0];
|
|
153
|
+
return [
|
|
154
|
+
{ op: ">=", v: v2 },
|
|
155
|
+
{ op: "<", v: upper }
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
let op = "=";
|
|
159
|
+
let rest = s;
|
|
160
|
+
if (c0 === ">" || c0 === "<") {
|
|
161
|
+
if (s[1] === "=") {
|
|
162
|
+
op = `${c0}=`;
|
|
163
|
+
rest = s.slice(2);
|
|
164
|
+
} else {
|
|
165
|
+
op = c0;
|
|
166
|
+
rest = s.slice(1);
|
|
167
|
+
}
|
|
168
|
+
} else if (c0 === "=") {
|
|
169
|
+
rest = s.slice(1);
|
|
170
|
+
}
|
|
171
|
+
const v = parseVersion(rest);
|
|
172
|
+
return v === null ? null : [{ op, v }];
|
|
173
|
+
}
|
|
174
|
+
function matchCompiled(range, version) {
|
|
175
|
+
for (const clause of range) if (matchClause(clause, version)) return true;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
function matchClause(clause, version) {
|
|
179
|
+
for (const c of clause) {
|
|
180
|
+
const d = cmpVersion(version, c.v);
|
|
181
|
+
const op = c.op;
|
|
182
|
+
const fail = op === "=" ? d !== 0 : op === "<" ? d >= 0 : op === "<=" ? d > 0 : op === ">" ? d <= 0 : d < 0;
|
|
183
|
+
if (fail) return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
function matchSemver(range, version) {
|
|
188
|
+
const r = parseSemver(range);
|
|
189
|
+
if (r === null) return false;
|
|
190
|
+
const v = parseVersion(version);
|
|
191
|
+
return v !== null && matchCompiled(r, v);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/config/errors.ts
|
|
195
|
+
var ConfigValidationError = class extends Error {
|
|
196
|
+
constructor(issues) {
|
|
197
|
+
super(`Config validation failed with ${issues.length} issue(s)`);
|
|
198
|
+
this.name = "ConfigValidationError";
|
|
199
|
+
this.issues = Object.freeze(issues.slice());
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// src/config/freeze.ts
|
|
204
|
+
function deepFreeze(value) {
|
|
205
|
+
if (value === null || typeof value !== "object") {
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
if (Object.isFrozen(value)) {
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(value)) {
|
|
212
|
+
for (let i = 0; i < value.length; i++) {
|
|
213
|
+
deepFreeze(value[i]);
|
|
214
|
+
}
|
|
215
|
+
return Object.freeze(value);
|
|
216
|
+
}
|
|
217
|
+
const obj = value;
|
|
218
|
+
const keys = Object.keys(obj);
|
|
219
|
+
for (let i = 0; i < keys.length; i++) {
|
|
220
|
+
deepFreeze(obj[keys[i]]);
|
|
221
|
+
}
|
|
222
|
+
return Object.freeze(value);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/config/validator.ts
|
|
226
|
+
var MAX_BYTES = 1048576;
|
|
227
|
+
var MAX_EXP = 1e3;
|
|
228
|
+
var MAX_VAR = 100;
|
|
229
|
+
var MIN_VAR = 2;
|
|
230
|
+
var MAX_ROUTES = 100;
|
|
231
|
+
var MAX_DEPTH = 10;
|
|
232
|
+
var MAX_NAME = 128;
|
|
233
|
+
var MAX_DESC = 512;
|
|
234
|
+
var ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
235
|
+
var RESERVED = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
236
|
+
var ASSIGNS = /* @__PURE__ */ new Set(["default", "random", "sticky-hash", "weighted"]);
|
|
237
|
+
var STATUSES = /* @__PURE__ */ new Set(["draft", "active", "archived"]);
|
|
238
|
+
var TYPES = /* @__PURE__ */ new Set(["render", "value"]);
|
|
239
|
+
var PLATFORMS = /* @__PURE__ */ new Set(["ios", "android", "web", "node"]);
|
|
240
|
+
var SIZES = /* @__PURE__ */ new Set(["small", "medium", "large"]);
|
|
241
|
+
function validateConfig(input) {
|
|
242
|
+
const issues = [];
|
|
243
|
+
if (measureBytes(input) > MAX_BYTES) {
|
|
244
|
+
push(issues, "", "config-too-large");
|
|
245
|
+
throw new ConfigValidationError(issues);
|
|
246
|
+
}
|
|
247
|
+
let parsed = input;
|
|
248
|
+
if (typeof input === "string") {
|
|
249
|
+
try {
|
|
250
|
+
parsed = JSON.parse(input);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
push(issues, "", "invalid-json", err.message);
|
|
253
|
+
throw new ConfigValidationError(issues);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
257
|
+
push(issues, "", "not-an-object");
|
|
258
|
+
throw new ConfigValidationError(issues);
|
|
259
|
+
}
|
|
260
|
+
const sanitized = sanitize(parsed, issues, "");
|
|
261
|
+
validateRoot(sanitized, issues);
|
|
262
|
+
if (issues.length > 0) {
|
|
263
|
+
throw new ConfigValidationError(issues);
|
|
264
|
+
}
|
|
265
|
+
return deepFreeze(sanitized);
|
|
266
|
+
}
|
|
267
|
+
var encoder = new TextEncoder();
|
|
268
|
+
function measureBytes(input) {
|
|
269
|
+
if (typeof input === "string") return encoder.encode(input).length;
|
|
270
|
+
try {
|
|
271
|
+
const s = JSON.stringify(input);
|
|
272
|
+
if (typeof s !== "string") return 0;
|
|
273
|
+
return encoder.encode(s).length;
|
|
274
|
+
} catch {
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function sanitize(value, issues, ptr) {
|
|
279
|
+
if (value === null || typeof value !== "object") return value;
|
|
280
|
+
if (Array.isArray(value)) {
|
|
281
|
+
const out = [];
|
|
282
|
+
for (let i = 0; i < value.length; i++) {
|
|
283
|
+
out.push(sanitize(value[i], issues, `${ptr}/${i}`));
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
const src = value;
|
|
288
|
+
const dst = /* @__PURE__ */ Object.create(null);
|
|
289
|
+
const keys = Object.keys(src);
|
|
290
|
+
for (let i = 0; i < keys.length; i++) {
|
|
291
|
+
const k = keys[i];
|
|
292
|
+
if (RESERVED.has(k)) {
|
|
293
|
+
push(issues, jp(ptr, k), "reserved-key", k);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
dst[k] = sanitize(src[k], issues, jp(ptr, k));
|
|
297
|
+
}
|
|
298
|
+
return dst;
|
|
299
|
+
}
|
|
300
|
+
function validateRoot(root, issues) {
|
|
301
|
+
const version = root["version"];
|
|
302
|
+
if (version === void 0) {
|
|
303
|
+
push(issues, "/version", "version/missing");
|
|
304
|
+
} else if (version !== 1) {
|
|
305
|
+
push(issues, "/version", "version/invalid");
|
|
306
|
+
}
|
|
307
|
+
const enabled = root["enabled"];
|
|
308
|
+
if (enabled !== void 0 && typeof enabled !== "boolean") {
|
|
309
|
+
push(issues, "/enabled", "enabled/invalid");
|
|
310
|
+
}
|
|
311
|
+
const signature = root["signature"];
|
|
312
|
+
if (signature !== void 0 && (typeof signature !== "string" || signature.length === 0)) {
|
|
313
|
+
push(issues, "/signature", "signature/invalid");
|
|
314
|
+
}
|
|
315
|
+
const experiments = root["experiments"];
|
|
316
|
+
if (experiments === void 0) {
|
|
317
|
+
push(issues, "/experiments", "experiments/missing");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(experiments)) {
|
|
321
|
+
push(issues, "/experiments", "experiments/not-an-array");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (experiments.length > MAX_EXP) {
|
|
325
|
+
push(issues, "/experiments", "experiments/too-many");
|
|
326
|
+
}
|
|
327
|
+
const seen = /* @__PURE__ */ new Set();
|
|
328
|
+
for (let i = 0; i < experiments.length; i++) {
|
|
329
|
+
validateExperiment(experiments[i], `/experiments/${i}`, seen, issues);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function validateExperiment(exp, p, seen, issues) {
|
|
333
|
+
if (exp === null || typeof exp !== "object" || Array.isArray(exp)) {
|
|
334
|
+
push(issues, p, "experiment/not-an-object");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const e = exp;
|
|
338
|
+
const id = e["id"];
|
|
339
|
+
if (id === void 0) {
|
|
340
|
+
push(issues, `${p}/id`, "experiment/missing-required", "id");
|
|
341
|
+
} else if (typeof id !== "string" || !ID_RE.test(id)) {
|
|
342
|
+
push(issues, `${p}/id`, "experiment/id/invalid");
|
|
343
|
+
} else if (seen.has(id)) {
|
|
344
|
+
push(issues, `${p}/id`, "experiment/id/duplicate", id);
|
|
345
|
+
} else {
|
|
346
|
+
seen.add(id);
|
|
347
|
+
}
|
|
348
|
+
const name = e["name"];
|
|
349
|
+
if (name === void 0) {
|
|
350
|
+
push(issues, `${p}/name`, "experiment/missing-required", "name");
|
|
351
|
+
} else if (typeof name !== "string" || name.length === 0 || name.length > MAX_NAME) {
|
|
352
|
+
push(issues, `${p}/name`, "experiment/name/invalid");
|
|
353
|
+
}
|
|
354
|
+
checkOptString(
|
|
355
|
+
e["description"],
|
|
356
|
+
`${p}/description`,
|
|
357
|
+
MAX_DESC,
|
|
358
|
+
"experiment/description/invalid",
|
|
359
|
+
issues
|
|
360
|
+
);
|
|
361
|
+
checkEnum(e["type"], `${p}/type`, TYPES, "experiment/type/invalid", issues);
|
|
362
|
+
checkEnum(e["status"], `${p}/status`, STATUSES, "experiment/status/invalid", issues);
|
|
363
|
+
checkOptString(e["mutex"], `${p}/mutex`, MAX_NAME, "experiment/mutex/invalid", issues);
|
|
364
|
+
checkOptString(e["owner"], `${p}/owner`, MAX_NAME, "experiment/owner/invalid", issues);
|
|
365
|
+
checkOptBool(e["overridable"], `${p}/overridable`, "experiment/overridable/invalid", issues);
|
|
366
|
+
const variantIds = validateVariants(e["variants"], p, issues);
|
|
367
|
+
validateDefault(e["default"], variantIds, p, issues);
|
|
368
|
+
validateAssignment(e["assignment"], e["split"], variantIds, p, issues);
|
|
369
|
+
validateRoutes(e["routes"], p, issues);
|
|
370
|
+
validateTargeting(e["targeting"], p, issues);
|
|
371
|
+
validateDates(e["startDate"], e["endDate"], p, issues);
|
|
372
|
+
validateRollback(e["rollback"], p, issues);
|
|
373
|
+
}
|
|
374
|
+
function validateVariants(field, p, issues) {
|
|
375
|
+
const ids = /* @__PURE__ */ new Set();
|
|
376
|
+
const vp = `${p}/variants`;
|
|
377
|
+
if (field === void 0) {
|
|
378
|
+
push(issues, vp, "experiment/variants/missing");
|
|
379
|
+
return ids;
|
|
380
|
+
}
|
|
381
|
+
if (!Array.isArray(field)) {
|
|
382
|
+
push(issues, vp, "experiment/variants/missing");
|
|
383
|
+
return ids;
|
|
384
|
+
}
|
|
385
|
+
if (field.length < MIN_VAR) {
|
|
386
|
+
push(issues, vp, "experiment/variants/too-few");
|
|
387
|
+
}
|
|
388
|
+
if (field.length > MAX_VAR) {
|
|
389
|
+
push(issues, vp, "experiment/variants/too-many");
|
|
390
|
+
}
|
|
391
|
+
for (let i = 0; i < field.length; i++) {
|
|
392
|
+
const v = field[i];
|
|
393
|
+
const vip = `${vp}/${i}`;
|
|
394
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
395
|
+
push(issues, vip, "variant/not-an-object");
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const vo = v;
|
|
399
|
+
const vid = vo["id"];
|
|
400
|
+
if (vid === void 0) {
|
|
401
|
+
push(issues, `${vip}/id`, "experiment/missing-required", "variant.id");
|
|
402
|
+
} else if (typeof vid !== "string" || !ID_RE.test(vid)) {
|
|
403
|
+
push(issues, `${vip}/id`, "variant/id/invalid");
|
|
404
|
+
} else if (ids.has(vid)) {
|
|
405
|
+
push(issues, `${vip}/id`, "variant/id/duplicate", vid);
|
|
406
|
+
} else {
|
|
407
|
+
ids.add(vid);
|
|
408
|
+
}
|
|
409
|
+
checkOptString(vo["label"], `${vip}/label`, MAX_NAME, "variant/label/invalid", issues);
|
|
410
|
+
checkOptString(
|
|
411
|
+
vo["description"],
|
|
412
|
+
`${vip}/description`,
|
|
413
|
+
MAX_DESC,
|
|
414
|
+
"variant/description/invalid",
|
|
415
|
+
issues
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return ids;
|
|
419
|
+
}
|
|
420
|
+
function validateDefault(field, ids, p, issues) {
|
|
421
|
+
const dp = `${p}/default`;
|
|
422
|
+
if (field === void 0) {
|
|
423
|
+
push(issues, dp, "experiment/default/missing");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (typeof field !== "string") {
|
|
427
|
+
push(issues, dp, "experiment/default/unknown-variant");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (ids.size > 0 && !ids.has(field)) {
|
|
431
|
+
push(issues, dp, "experiment/default/unknown-variant", field);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function validateAssignment(aField, sField, ids, p, issues) {
|
|
435
|
+
let strategy = "default";
|
|
436
|
+
if (aField !== void 0) {
|
|
437
|
+
if (typeof aField !== "string" || !ASSIGNS.has(aField)) {
|
|
438
|
+
push(issues, `${p}/assignment`, "experiment/assignment/invalid");
|
|
439
|
+
} else {
|
|
440
|
+
strategy = aField;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const sp = `${p}/split`;
|
|
444
|
+
if (strategy === "weighted" && sField === void 0) {
|
|
445
|
+
push(issues, sp, "split/missing");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (sField === void 0) return;
|
|
449
|
+
if (sField === null || typeof sField !== "object" || Array.isArray(sField)) {
|
|
450
|
+
push(issues, sp, "split/not-an-object");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const split = sField;
|
|
454
|
+
const keys = Object.keys(split);
|
|
455
|
+
let sum = 0;
|
|
456
|
+
let bad = false;
|
|
457
|
+
for (let i = 0; i < keys.length; i++) {
|
|
458
|
+
const k = keys[i];
|
|
459
|
+
if (ids.size > 0 && !ids.has(k)) {
|
|
460
|
+
push(issues, `${sp}/${esc(k)}`, "split/unknown-variant", k);
|
|
461
|
+
bad = true;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const v = split[k];
|
|
465
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 100) {
|
|
466
|
+
push(issues, `${sp}/${esc(k)}`, "split/value-invalid");
|
|
467
|
+
bad = true;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
sum += v;
|
|
471
|
+
}
|
|
472
|
+
if (!bad && sum !== 100) {
|
|
473
|
+
push(issues, sp, "split/sum-invalid", String(sum));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function validateRoutes(field, p, issues) {
|
|
477
|
+
if (field === void 0) return;
|
|
478
|
+
const rp = `${p}/routes`;
|
|
479
|
+
if (!Array.isArray(field)) {
|
|
480
|
+
push(issues, rp, "experiment/routes/invalid");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (field.length > MAX_ROUTES) {
|
|
484
|
+
push(issues, rp, "experiment/routes/invalid");
|
|
485
|
+
}
|
|
486
|
+
for (let i = 0; i < field.length; i++) {
|
|
487
|
+
const r = field[i];
|
|
488
|
+
if (typeof r !== "string" || compileGlob(r) === null) {
|
|
489
|
+
push(issues, `${rp}/${i}`, "route/glob/invalid");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function validateTargeting(field, p, issues) {
|
|
494
|
+
if (field === void 0) return;
|
|
495
|
+
const tp = `${p}/targeting`;
|
|
496
|
+
if (field === null || typeof field !== "object" || Array.isArray(field)) {
|
|
497
|
+
push(issues, tp, "targeting/not-an-object");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (depthOf(field, 0) > MAX_DEPTH) {
|
|
501
|
+
push(issues, tp, "targeting/depth-exceeded");
|
|
502
|
+
}
|
|
503
|
+
const t = field;
|
|
504
|
+
if (t["platform"] !== void 0) {
|
|
505
|
+
checkEnumArray(
|
|
506
|
+
t["platform"],
|
|
507
|
+
`${tp}/platform`,
|
|
508
|
+
PLATFORMS,
|
|
509
|
+
"targeting/platform/invalid",
|
|
510
|
+
issues
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (t["appVersion"] !== void 0) {
|
|
514
|
+
const v = t["appVersion"];
|
|
515
|
+
if (typeof v !== "string" || parseSemver(v) === null) {
|
|
516
|
+
push(issues, `${tp}/appVersion`, "targeting/appversion/invalid");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (t["locale"] !== void 0) {
|
|
520
|
+
checkStringArray(t["locale"], `${tp}/locale`, "targeting/locale/invalid", issues);
|
|
521
|
+
}
|
|
522
|
+
if (t["screenSize"] !== void 0) {
|
|
523
|
+
checkEnumArray(
|
|
524
|
+
t["screenSize"],
|
|
525
|
+
`${tp}/screenSize`,
|
|
526
|
+
SIZES,
|
|
527
|
+
"targeting/screensize/invalid",
|
|
528
|
+
issues
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
if (t["routes"] !== void 0) {
|
|
532
|
+
const r = t["routes"];
|
|
533
|
+
if (!Array.isArray(r) || r.length === 0) {
|
|
534
|
+
push(issues, `${tp}/routes`, "targeting/routes/invalid");
|
|
535
|
+
} else {
|
|
536
|
+
for (let i = 0; i < r.length; i++) {
|
|
537
|
+
const v = r[i];
|
|
538
|
+
if (typeof v !== "string" || compileGlob(v) === null) {
|
|
539
|
+
push(issues, `${tp}/routes/${i}`, "targeting/routes/invalid");
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (t["userId"] !== void 0) {
|
|
545
|
+
validateUserId(t["userId"], `${tp}/userId`, issues);
|
|
546
|
+
}
|
|
547
|
+
if (t["attributes"] !== void 0) {
|
|
548
|
+
const a = t["attributes"];
|
|
549
|
+
if (a === null || typeof a !== "object" || Array.isArray(a)) {
|
|
550
|
+
push(issues, `${tp}/attributes`, "targeting/attributes/invalid");
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function validateUserId(u, p, issues) {
|
|
555
|
+
const code = "targeting/userid/invalid";
|
|
556
|
+
if (Array.isArray(u)) {
|
|
557
|
+
if (u.length === 0) {
|
|
558
|
+
push(issues, p, code);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
for (let i = 0; i < u.length; i++) {
|
|
562
|
+
const v = u[i];
|
|
563
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
564
|
+
push(issues, `${p}/${i}`, code);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (u !== null && typeof u === "object") {
|
|
570
|
+
const o = u;
|
|
571
|
+
const h = o["hash"];
|
|
572
|
+
const m = o["mod"];
|
|
573
|
+
if (typeof h !== "string" || h.length === 0) {
|
|
574
|
+
push(issues, `${p}/hash`, code);
|
|
575
|
+
}
|
|
576
|
+
if (typeof m !== "number" || !Number.isInteger(m) || m < 0 || m > 100) {
|
|
577
|
+
push(issues, `${p}/mod`, code);
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
push(issues, p, code);
|
|
582
|
+
}
|
|
583
|
+
function validateDates(sField, eField, p, issues) {
|
|
584
|
+
let s;
|
|
585
|
+
let e;
|
|
586
|
+
if (sField !== void 0) {
|
|
587
|
+
if (typeof sField !== "string" || !isValidIsoDate(sField)) {
|
|
588
|
+
push(issues, `${p}/startDate`, "experiment/startdate/invalid");
|
|
589
|
+
} else {
|
|
590
|
+
s = Date.parse(sField);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (eField !== void 0) {
|
|
594
|
+
if (typeof eField !== "string" || !isValidIsoDate(eField)) {
|
|
595
|
+
push(issues, `${p}/endDate`, "experiment/enddate/invalid");
|
|
596
|
+
} else {
|
|
597
|
+
e = Date.parse(eField);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (s !== void 0 && e !== void 0 && !(e > s)) {
|
|
601
|
+
push(issues, `${p}/endDate`, "experiment/date-range/invalid");
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function validateRollback(field, p, issues) {
|
|
605
|
+
if (field === void 0) return;
|
|
606
|
+
const rp = `${p}/rollback`;
|
|
607
|
+
if (field === null || typeof field !== "object" || Array.isArray(field)) {
|
|
608
|
+
push(issues, rp, "rollback/not-an-object");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const r = field;
|
|
612
|
+
const thr = r["threshold"];
|
|
613
|
+
if (thr === void 0) {
|
|
614
|
+
push(issues, `${rp}/threshold`, "rollback/threshold/invalid");
|
|
615
|
+
} else if (typeof thr !== "number" || !Number.isInteger(thr) || thr < 1 || thr > 100) {
|
|
616
|
+
push(issues, `${rp}/threshold`, "rollback/threshold/invalid");
|
|
617
|
+
}
|
|
618
|
+
const win = r["window"];
|
|
619
|
+
if (win === void 0) {
|
|
620
|
+
push(issues, `${rp}/window`, "rollback/window/invalid");
|
|
621
|
+
} else if (typeof win !== "number" || !Number.isInteger(win) || win < 1e3 || win > 36e5) {
|
|
622
|
+
push(issues, `${rp}/window`, "rollback/window/invalid");
|
|
623
|
+
}
|
|
624
|
+
const persistent = r["persistent"];
|
|
625
|
+
if (persistent !== void 0 && typeof persistent !== "boolean") {
|
|
626
|
+
push(issues, `${rp}/persistent`, "rollback/persistent/invalid");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function checkOptString(v, p, max, code, issues) {
|
|
630
|
+
if (v === void 0) return;
|
|
631
|
+
if (typeof v !== "string" || v.length === 0 || v.length > max) {
|
|
632
|
+
push(issues, p, code);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function checkOptBool(v, p, code, issues) {
|
|
636
|
+
if (v === void 0) return;
|
|
637
|
+
if (typeof v !== "boolean") {
|
|
638
|
+
push(issues, p, code);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function checkEnum(v, p, set, code, issues) {
|
|
642
|
+
if (v === void 0) return;
|
|
643
|
+
if (typeof v !== "string" || !set.has(v)) {
|
|
644
|
+
push(issues, p, code);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function checkEnumArray(v, p, set, code, issues) {
|
|
648
|
+
if (!Array.isArray(v) || v.length === 0) {
|
|
649
|
+
push(issues, p, code);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
for (let i = 0; i < v.length; i++) {
|
|
653
|
+
const x = v[i];
|
|
654
|
+
if (typeof x !== "string" || !set.has(x)) {
|
|
655
|
+
push(issues, `${p}/${i}`, code);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function checkStringArray(v, p, code, issues) {
|
|
660
|
+
if (!Array.isArray(v) || v.length === 0) {
|
|
661
|
+
push(issues, p, code);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
for (let i = 0; i < v.length; i++) {
|
|
665
|
+
const x = v[i];
|
|
666
|
+
if (typeof x !== "string" || x.length === 0) {
|
|
667
|
+
push(issues, `${p}/${i}`, code);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
var ISO_TAIL_RE = /T.*(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
672
|
+
function isValidIsoDate(s) {
|
|
673
|
+
if (s.length < 10) return false;
|
|
674
|
+
if (Number.isNaN(Date.parse(s))) return false;
|
|
675
|
+
return ISO_TAIL_RE.test(s);
|
|
676
|
+
}
|
|
677
|
+
function depthOf(value, current) {
|
|
678
|
+
if (value === null || typeof value !== "object") return current;
|
|
679
|
+
let m = current;
|
|
680
|
+
if (Array.isArray(value)) {
|
|
681
|
+
for (let i = 0; i < value.length; i++) {
|
|
682
|
+
const d = depthOf(value[i], current + 1);
|
|
683
|
+
if (d > m) m = d;
|
|
684
|
+
}
|
|
685
|
+
return m;
|
|
686
|
+
}
|
|
687
|
+
const obj = value;
|
|
688
|
+
const keys = Object.keys(obj);
|
|
689
|
+
for (let i = 0; i < keys.length; i++) {
|
|
690
|
+
const d = depthOf(obj[keys[i]], current + 1);
|
|
691
|
+
if (d > m) m = d;
|
|
692
|
+
}
|
|
693
|
+
return m;
|
|
694
|
+
}
|
|
695
|
+
function jp(parent, key) {
|
|
696
|
+
return `${parent}/${esc(key)}`;
|
|
697
|
+
}
|
|
698
|
+
function esc(s) {
|
|
699
|
+
return s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
700
|
+
}
|
|
701
|
+
function push(issues, path, code, message) {
|
|
702
|
+
issues.push({ path, code, message: message ?? code });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/assignment/hash.ts
|
|
706
|
+
function hash32(input) {
|
|
707
|
+
let h = 2166136261;
|
|
708
|
+
for (let i = 0; i < input.length; i++) {
|
|
709
|
+
h ^= input.charCodeAt(i);
|
|
710
|
+
h = Math.imul(h, 16777619);
|
|
711
|
+
}
|
|
712
|
+
h ^= h >>> 16;
|
|
713
|
+
h = Math.imul(h, 2246822507);
|
|
714
|
+
h ^= h >>> 13;
|
|
715
|
+
h = Math.imul(h, 3266489909);
|
|
716
|
+
h ^= h >>> 16;
|
|
717
|
+
return h >>> 0;
|
|
718
|
+
}
|
|
719
|
+
function bucketUserId(userId) {
|
|
720
|
+
return hash32(userId) % 100;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// src/assignment/default.ts
|
|
724
|
+
function assignDefault(experiment) {
|
|
725
|
+
return experiment.default;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/assignment/sticky-hash.ts
|
|
729
|
+
function assignStickyHash(experiment, userId) {
|
|
730
|
+
const ids = experiment.variants.map((v) => v.id).sort();
|
|
731
|
+
const bucket = hash32(`${userId}:${experiment.id}`) % ids.length;
|
|
732
|
+
return ids[bucket] ?? experiment.default;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/assignment/random.ts
|
|
736
|
+
function assignRandom(experiment, userId) {
|
|
737
|
+
return assignStickyHash(experiment, userId);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// src/assignment/weighted.ts
|
|
741
|
+
function assignWeighted(experiment, userId) {
|
|
742
|
+
const split = experiment.split;
|
|
743
|
+
if (split === void 0) return experiment.default;
|
|
744
|
+
const ids = Object.keys(split).sort();
|
|
745
|
+
if (ids.length === 0) return experiment.default;
|
|
746
|
+
const bucket = hash32(`${userId}:${experiment.id}`) % 1e4;
|
|
747
|
+
let cumulative = 0;
|
|
748
|
+
for (const id of ids) {
|
|
749
|
+
cumulative += (split[id] ?? 0) * 100;
|
|
750
|
+
if (bucket < cumulative) return id;
|
|
751
|
+
}
|
|
752
|
+
return experiment.default;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/assignment/mutex.ts
|
|
756
|
+
function resolveMutex(userId, mutexGroup, candidateIds) {
|
|
757
|
+
if (candidateIds.length === 0) return void 0;
|
|
758
|
+
const sorted = candidateIds.slice().sort();
|
|
759
|
+
const bucket = hash32(`${userId}:${mutexGroup}`) % sorted.length;
|
|
760
|
+
return sorted[bucket];
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/assignment/index.ts
|
|
764
|
+
function assignVariant(experiment, userId) {
|
|
765
|
+
const strategy = experiment.assignment ?? "default";
|
|
766
|
+
if (strategy === "default") return assignDefault(experiment);
|
|
767
|
+
if (userId === void 0 || userId === "") return assignDefault(experiment);
|
|
768
|
+
if (strategy === "random") return assignRandom(experiment, userId);
|
|
769
|
+
if (strategy === "sticky-hash") return assignStickyHash(experiment, userId);
|
|
770
|
+
if (strategy === "weighted") return assignWeighted(experiment, userId);
|
|
771
|
+
return assignDefault(experiment);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/history/ring-buffer.ts
|
|
775
|
+
var RingBuffer = class {
|
|
776
|
+
constructor(capacity = 500) {
|
|
777
|
+
this.head = 0;
|
|
778
|
+
this.count = 0;
|
|
779
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
780
|
+
throw new Error("RingBuffer capacity must be a positive integer");
|
|
781
|
+
}
|
|
782
|
+
this.capacity = capacity;
|
|
783
|
+
this.buffer = new Array(capacity);
|
|
784
|
+
}
|
|
785
|
+
push(item) {
|
|
786
|
+
this.buffer[this.head] = item;
|
|
787
|
+
this.head = (this.head + 1) % this.capacity;
|
|
788
|
+
if (this.count < this.capacity) this.count++;
|
|
789
|
+
}
|
|
790
|
+
toArray() {
|
|
791
|
+
const out = [];
|
|
792
|
+
const start = this.count < this.capacity ? 0 : this.head;
|
|
793
|
+
for (let i = 0; i < this.count; i++) {
|
|
794
|
+
const item = this.buffer[(start + i) % this.capacity];
|
|
795
|
+
if (item !== void 0) out.push(item);
|
|
796
|
+
}
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
clear() {
|
|
800
|
+
this.buffer = new Array(this.capacity);
|
|
801
|
+
this.head = 0;
|
|
802
|
+
this.count = 0;
|
|
803
|
+
}
|
|
804
|
+
get size() {
|
|
805
|
+
return this.count;
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
// src/targeting/operators/app-version.ts
|
|
810
|
+
function matchAppVersion(range, ctxVersion) {
|
|
811
|
+
if (ctxVersion === void 0) return false;
|
|
812
|
+
return matchSemver(range, ctxVersion);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/targeting/operators/attributes.ts
|
|
816
|
+
function matchAttributes(target, ctxAttrs) {
|
|
817
|
+
for (const k of Object.keys(target)) {
|
|
818
|
+
if (ctxAttrs === void 0 || ctxAttrs[k] !== target[k]) return false;
|
|
819
|
+
}
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/targeting/operators/locale.ts
|
|
824
|
+
function matchLocale(target, ctxLocale) {
|
|
825
|
+
if (ctxLocale === void 0) return false;
|
|
826
|
+
for (const t of target) {
|
|
827
|
+
if (ctxLocale === t || ctxLocale.startsWith(`${t}-`)) return true;
|
|
828
|
+
}
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// src/targeting/operators/platform.ts
|
|
833
|
+
function matchPlatform(target, ctxPlatform) {
|
|
834
|
+
return ctxPlatform !== void 0 && target.includes(ctxPlatform);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/targeting/operators/routes.ts
|
|
838
|
+
function matchRoutes(target, ctxRoute) {
|
|
839
|
+
if (ctxRoute === void 0) return false;
|
|
840
|
+
for (let i = 0; i < target.length; i++) {
|
|
841
|
+
if (matchRoute(target[i], ctxRoute)) return true;
|
|
842
|
+
}
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// src/targeting/operators/screen-size.ts
|
|
847
|
+
function matchScreenSize(target, ctxSize) {
|
|
848
|
+
return ctxSize !== void 0 && target.includes(ctxSize);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// src/targeting/operators/user-id.ts
|
|
852
|
+
function matchUserId(target, context) {
|
|
853
|
+
if ("mod" in target) {
|
|
854
|
+
const bucket = context.userIdBucket;
|
|
855
|
+
return typeof bucket === "number" && bucket < target.mod;
|
|
856
|
+
}
|
|
857
|
+
return context.userId !== void 0 && target.includes(context.userId);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/targeting/evaluator.ts
|
|
861
|
+
function evaluate(targeting, context) {
|
|
862
|
+
const c = context;
|
|
863
|
+
if (targeting.platform !== void 0 && !matchPlatform(targeting.platform, c.platform))
|
|
864
|
+
return { matched: false, reason: "platform" };
|
|
865
|
+
if (targeting.screenSize !== void 0 && !matchScreenSize(targeting.screenSize, c.screenSize))
|
|
866
|
+
return { matched: false, reason: "screenSize" };
|
|
867
|
+
if (targeting.locale !== void 0 && !matchLocale(targeting.locale, c.locale))
|
|
868
|
+
return { matched: false, reason: "locale" };
|
|
869
|
+
if (targeting.appVersion !== void 0 && !matchAppVersion(targeting.appVersion, c.appVersion))
|
|
870
|
+
return { matched: false, reason: "appVersion" };
|
|
871
|
+
if (targeting.routes !== void 0 && !matchRoutes(targeting.routes, c.route))
|
|
872
|
+
return { matched: false, reason: "routes" };
|
|
873
|
+
if (targeting.attributes !== void 0 && !matchAttributes(targeting.attributes, c.attributes))
|
|
874
|
+
return { matched: false, reason: "attributes" };
|
|
875
|
+
if (targeting.userId !== void 0 && !matchUserId(targeting.userId, c))
|
|
876
|
+
return { matched: false, reason: "userId" };
|
|
877
|
+
if (targeting.predicate !== void 0 && !targeting.predicate(c))
|
|
878
|
+
return { matched: false, reason: "predicate" };
|
|
879
|
+
return { matched: true };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// src/engine/kill-switch.ts
|
|
883
|
+
function isKilled(config, experiment) {
|
|
884
|
+
if (config.enabled === false) return true;
|
|
885
|
+
if (experiment.status === "archived") return true;
|
|
886
|
+
if (experiment.status === "draft") return true;
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// src/engine/subscribe.ts
|
|
891
|
+
var ListenerSet = class {
|
|
892
|
+
constructor() {
|
|
893
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
894
|
+
}
|
|
895
|
+
add(listener) {
|
|
896
|
+
this.listeners.add(listener);
|
|
897
|
+
return () => {
|
|
898
|
+
this.listeners.delete(listener);
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
emit(event) {
|
|
902
|
+
for (const listener of Array.from(this.listeners)) {
|
|
903
|
+
try {
|
|
904
|
+
listener(event);
|
|
905
|
+
} catch {
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
clear() {
|
|
910
|
+
this.listeners.clear();
|
|
911
|
+
}
|
|
912
|
+
get size() {
|
|
913
|
+
return this.listeners.size;
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
// src/engine/time-gate.ts
|
|
918
|
+
function isTimeGated(experiment, now) {
|
|
919
|
+
if (experiment.startDate !== void 0) {
|
|
920
|
+
const t = Date.parse(experiment.startDate);
|
|
921
|
+
if (!Number.isFinite(t) || now < t) return true;
|
|
922
|
+
}
|
|
923
|
+
if (experiment.endDate !== void 0) {
|
|
924
|
+
const t = Date.parse(experiment.endDate);
|
|
925
|
+
if (!Number.isFinite(t) || now >= t) return true;
|
|
926
|
+
}
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// src/engine/engine.ts
|
|
931
|
+
var UnknownExperimentError = class extends Error {
|
|
932
|
+
constructor(experimentId) {
|
|
933
|
+
super(`Unknown experiment: ${experimentId}`);
|
|
934
|
+
this.name = "UnknownExperimentError";
|
|
935
|
+
this.experimentId = experimentId;
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
var VariantEngine = class {
|
|
939
|
+
constructor(config, options = {}) {
|
|
940
|
+
this.listeners = new ListenerSet();
|
|
941
|
+
this.overrides = /* @__PURE__ */ new Map();
|
|
942
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
943
|
+
this.rolledBack = /* @__PURE__ */ new Set();
|
|
944
|
+
this.crashCounter = new CrashCounter();
|
|
945
|
+
this.disposed = false;
|
|
946
|
+
this.config = config;
|
|
947
|
+
this.experimentIndex = indexExperiments(config);
|
|
948
|
+
this.context = options.context ?? {};
|
|
949
|
+
this.evalContext = buildEvalContext(this.context);
|
|
950
|
+
this.failMode = options.failMode ?? "fail-open";
|
|
951
|
+
this.history = new RingBuffer(options.historySize ?? 500);
|
|
952
|
+
this.seedInitialAssignments(options.initialAssignments);
|
|
953
|
+
this.emit({ type: "ready", config });
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Copy caller-supplied `{ experimentId: variantId }` pairs into the
|
|
957
|
+
* in-memory cache so the next `getVariant` call returns them without
|
|
958
|
+
* re-evaluating targeting. Invalid pairs are silently dropped — seeding
|
|
959
|
+
* is best-effort hydration, never a hard error.
|
|
960
|
+
*/
|
|
961
|
+
seedInitialAssignments(seed) {
|
|
962
|
+
if (seed === void 0) return;
|
|
963
|
+
for (const experimentId of Object.keys(seed)) {
|
|
964
|
+
const variantId = seed[experimentId];
|
|
965
|
+
if (typeof variantId !== "string" || variantId.length === 0) continue;
|
|
966
|
+
const experiment = this.experimentIndex.get(experimentId);
|
|
967
|
+
if (experiment === void 0) continue;
|
|
968
|
+
if (!experiment.variants.some((v) => v.id === variantId)) continue;
|
|
969
|
+
this.cache.set(experimentId, variantId);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// ---------- resolution ---------------------------------------------------
|
|
973
|
+
getVariant(experimentId) {
|
|
974
|
+
if (this.disposed) {
|
|
975
|
+
return this.handleError(new Error("VariantEngine is disposed"), experimentId);
|
|
976
|
+
}
|
|
977
|
+
try {
|
|
978
|
+
const override = this.overrides.get(experimentId);
|
|
979
|
+
if (override !== void 0) return override;
|
|
980
|
+
const cached = this.cache.get(experimentId);
|
|
981
|
+
if (cached !== void 0) return cached;
|
|
982
|
+
const experiment = this.experimentIndex.get(experimentId);
|
|
983
|
+
if (experiment === void 0) {
|
|
984
|
+
if (this.failMode === "fail-closed") {
|
|
985
|
+
throw new UnknownExperimentError(experimentId);
|
|
986
|
+
}
|
|
987
|
+
return this.handleError(new UnknownExperimentError(experimentId), experimentId);
|
|
988
|
+
}
|
|
989
|
+
if (this.rolledBack.has(experimentId)) return experiment.default;
|
|
990
|
+
if (isKilled(this.config, experiment)) return experiment.default;
|
|
991
|
+
if (isTimeGated(experiment, Date.now())) return experiment.default;
|
|
992
|
+
if (!this.isTargeted(experiment)) return experiment.default;
|
|
993
|
+
if (experiment.mutex !== void 0) {
|
|
994
|
+
const winner = this.resolveMutexWinner(experiment.mutex);
|
|
995
|
+
if (winner !== experimentId) return experiment.default;
|
|
996
|
+
}
|
|
997
|
+
const variantId = assignVariant(experiment, this.context.userId);
|
|
998
|
+
this.cache.set(experimentId, variantId);
|
|
999
|
+
this.emit({ type: "assignment", experimentId, variantId, context: this.context });
|
|
1000
|
+
return variantId;
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
if (this.failMode === "fail-closed") throw err;
|
|
1003
|
+
return this.handleError(err, experimentId);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
getVariantValue(experimentId) {
|
|
1007
|
+
const variantId = this.getVariant(experimentId);
|
|
1008
|
+
const experiment = this.experimentIndex.get(experimentId);
|
|
1009
|
+
if (experiment === void 0) return void 0;
|
|
1010
|
+
const variant = experiment.variants.find((v) => v.id === variantId);
|
|
1011
|
+
return variant?.value ?? void 0;
|
|
1012
|
+
}
|
|
1013
|
+
// ---------- overrides ---------------------------------------------------
|
|
1014
|
+
setVariant(experimentId, variantId, source = "user") {
|
|
1015
|
+
if (this.disposed) return;
|
|
1016
|
+
const experiment = this.experimentIndex.get(experimentId);
|
|
1017
|
+
if (experiment === void 0) return;
|
|
1018
|
+
if (!experiment.variants.some((v) => v.id === variantId)) return;
|
|
1019
|
+
this.overrides.set(experimentId, variantId);
|
|
1020
|
+
this.emit({ type: "variantChanged", experimentId, variantId, source });
|
|
1021
|
+
}
|
|
1022
|
+
clearVariant(experimentId) {
|
|
1023
|
+
if (this.disposed) return;
|
|
1024
|
+
if (!this.overrides.delete(experimentId)) return;
|
|
1025
|
+
const experiment = this.experimentIndex.get(experimentId);
|
|
1026
|
+
if (experiment === void 0) return;
|
|
1027
|
+
const next = this.getVariant(experimentId);
|
|
1028
|
+
this.emit({ type: "variantChanged", experimentId, variantId: next, source: "system" });
|
|
1029
|
+
}
|
|
1030
|
+
resetAll() {
|
|
1031
|
+
if (this.disposed) return;
|
|
1032
|
+
this.overrides.clear();
|
|
1033
|
+
this.cache.clear();
|
|
1034
|
+
this.rolledBack.clear();
|
|
1035
|
+
for (const experiment of this.config.experiments) {
|
|
1036
|
+
this.emit({
|
|
1037
|
+
type: "variantChanged",
|
|
1038
|
+
experimentId: experiment.id,
|
|
1039
|
+
variantId: this.getVariant(experiment.id),
|
|
1040
|
+
source: "system"
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
// ---------- lookup ------------------------------------------------------
|
|
1045
|
+
/**
|
|
1046
|
+
* Returns the currently loaded, deeply-frozen {@link ExperimentsConfig}.
|
|
1047
|
+
*
|
|
1048
|
+
* Exposed read-only so debug surfaces (e.g. `<VariantDebugOverlay />`)
|
|
1049
|
+
* can display the raw config without reaching into private fields.
|
|
1050
|
+
* Mutating this value has no effect on the engine.
|
|
1051
|
+
*/
|
|
1052
|
+
getConfig() {
|
|
1053
|
+
return this.config;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Returns a shallow copy of the current {@link VariantContext}.
|
|
1057
|
+
*
|
|
1058
|
+
* A copy is returned rather than the live object so callers can't
|
|
1059
|
+
* accidentally mutate engine state. Use {@link updateContext} to
|
|
1060
|
+
* change it.
|
|
1061
|
+
*/
|
|
1062
|
+
getContext() {
|
|
1063
|
+
return { ...this.context };
|
|
1064
|
+
}
|
|
1065
|
+
getExperiments(route) {
|
|
1066
|
+
if (route === void 0) return this.config.experiments;
|
|
1067
|
+
return this.config.experiments.filter((exp) => {
|
|
1068
|
+
if (exp.routes === void 0) return true;
|
|
1069
|
+
return exp.routes.some((pattern) => matchRoute(pattern, route));
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
// ---------- subscriptions ----------------------------------------------
|
|
1073
|
+
subscribe(listener) {
|
|
1074
|
+
return this.listeners.add(listener);
|
|
1075
|
+
}
|
|
1076
|
+
// ---------- mutation ----------------------------------------------------
|
|
1077
|
+
updateContext(patch) {
|
|
1078
|
+
if (this.disposed) return;
|
|
1079
|
+
const merged = { ...this.context, ...patch };
|
|
1080
|
+
this.context = merged;
|
|
1081
|
+
this.evalContext = buildEvalContext(merged);
|
|
1082
|
+
this.cache.clear();
|
|
1083
|
+
this.emit({ type: "contextUpdated", context: merged });
|
|
1084
|
+
}
|
|
1085
|
+
async loadConfig(next) {
|
|
1086
|
+
if (this.disposed) return;
|
|
1087
|
+
const validated = validateConfig(next);
|
|
1088
|
+
this.config = validated;
|
|
1089
|
+
this.experimentIndex = indexExperiments(validated);
|
|
1090
|
+
this.cache.clear();
|
|
1091
|
+
this.rolledBack.clear();
|
|
1092
|
+
this.crashCounter.clear();
|
|
1093
|
+
this.emit({ type: "configLoaded", config: validated });
|
|
1094
|
+
}
|
|
1095
|
+
// ---------- crash rollback ---------------------------------------------
|
|
1096
|
+
reportCrash(experimentId, _error) {
|
|
1097
|
+
if (this.disposed) return;
|
|
1098
|
+
const experiment = this.experimentIndex.get(experimentId);
|
|
1099
|
+
if (experiment === void 0) return;
|
|
1100
|
+
const rollback = experiment.rollback;
|
|
1101
|
+
if (rollback === void 0) return;
|
|
1102
|
+
const now = Date.now();
|
|
1103
|
+
const count = this.crashCounter.record(experimentId, now, rollback.window);
|
|
1104
|
+
if (count >= rollback.threshold) {
|
|
1105
|
+
this.rolledBack.add(experimentId);
|
|
1106
|
+
this.cache.delete(experimentId);
|
|
1107
|
+
this.overrides.delete(experimentId);
|
|
1108
|
+
this.emit({
|
|
1109
|
+
type: "rollback",
|
|
1110
|
+
experimentId,
|
|
1111
|
+
variantId: experiment.default,
|
|
1112
|
+
reason: `threshold ${rollback.threshold} crashes in ${rollback.window}ms`
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// ---------- history -----------------------------------------------------
|
|
1117
|
+
getHistory() {
|
|
1118
|
+
return this.history.toArray();
|
|
1119
|
+
}
|
|
1120
|
+
// ---------- lifecycle ---------------------------------------------------
|
|
1121
|
+
dispose() {
|
|
1122
|
+
if (this.disposed) return;
|
|
1123
|
+
this.disposed = true;
|
|
1124
|
+
this.listeners.clear();
|
|
1125
|
+
this.overrides.clear();
|
|
1126
|
+
this.cache.clear();
|
|
1127
|
+
this.rolledBack.clear();
|
|
1128
|
+
this.crashCounter.clear();
|
|
1129
|
+
}
|
|
1130
|
+
// ---------- internals ---------------------------------------------------
|
|
1131
|
+
isTargeted(experiment) {
|
|
1132
|
+
if (experiment.targeting === void 0) return true;
|
|
1133
|
+
return evaluate(experiment.targeting, this.evalContext).matched;
|
|
1134
|
+
}
|
|
1135
|
+
resolveMutexWinner(group) {
|
|
1136
|
+
const userId = this.context.userId;
|
|
1137
|
+
if (userId === void 0 || userId === "") return void 0;
|
|
1138
|
+
const candidates = [];
|
|
1139
|
+
const now = Date.now();
|
|
1140
|
+
for (const exp of this.config.experiments) {
|
|
1141
|
+
if (exp.mutex !== group) continue;
|
|
1142
|
+
if (this.rolledBack.has(exp.id)) continue;
|
|
1143
|
+
if (isKilled(this.config, exp)) continue;
|
|
1144
|
+
if (isTimeGated(exp, now)) continue;
|
|
1145
|
+
if (!this.isTargeted(exp)) continue;
|
|
1146
|
+
candidates.push(exp.id);
|
|
1147
|
+
}
|
|
1148
|
+
return resolveMutex(userId, group, candidates);
|
|
1149
|
+
}
|
|
1150
|
+
emit(event) {
|
|
1151
|
+
this.history.push(event);
|
|
1152
|
+
this.listeners.emit(event);
|
|
1153
|
+
}
|
|
1154
|
+
handleError(error, experimentId) {
|
|
1155
|
+
const experiment = this.experimentIndex.get(experimentId);
|
|
1156
|
+
this.emit({ type: "error", error });
|
|
1157
|
+
return experiment?.default ?? "";
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
function buildEvalContext(ctx) {
|
|
1161
|
+
if (ctx.userId === void 0) return ctx;
|
|
1162
|
+
return { ...ctx, userIdBucket: bucketUserId(ctx.userId) };
|
|
1163
|
+
}
|
|
1164
|
+
function indexExperiments(config) {
|
|
1165
|
+
const map = /* @__PURE__ */ new Map();
|
|
1166
|
+
for (const exp of config.experiments) map.set(exp.id, exp);
|
|
1167
|
+
return map;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/engine/create.ts
|
|
1171
|
+
function createEngine(config, options = {}) {
|
|
1172
|
+
return new VariantEngine(validateConfig(config), options);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
exports.VariantEngine = VariantEngine;
|
|
1176
|
+
exports.createEngine = createEngine;
|
|
1177
|
+
//# sourceMappingURL=engine.cjs.map
|
|
1178
|
+
//# sourceMappingURL=engine.cjs.map
|