@runbandit/sdk 2.0.0 → 2.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/README.md +3 -1
- package/dist/index.d.ts +169 -166
- package/dist/index.js +501 -56
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -34,6 +34,12 @@ function defaultSessionStorage() {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// src/client.ts
|
|
37
|
+
function currentPageUrl() {
|
|
38
|
+
if (typeof location !== "undefined" && location.origin && location.pathname) {
|
|
39
|
+
return location.origin + location.pathname;
|
|
40
|
+
}
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
37
43
|
var BanditsClient = class {
|
|
38
44
|
config;
|
|
39
45
|
namedEventQueue = [];
|
|
@@ -45,7 +51,6 @@ var BanditsClient = class {
|
|
|
45
51
|
batchSize: 100,
|
|
46
52
|
flushInterval: 1e4,
|
|
47
53
|
sessionTtl: 864e5,
|
|
48
|
-
// 24 hours in ms
|
|
49
54
|
...config
|
|
50
55
|
};
|
|
51
56
|
this.sessionId = this.getOrCreateSessionId();
|
|
@@ -58,6 +63,8 @@ var BanditsClient = class {
|
|
|
58
63
|
context,
|
|
59
64
|
sessionId: this.sessionId
|
|
60
65
|
};
|
|
66
|
+
const pageUrl = currentPageUrl();
|
|
67
|
+
if (pageUrl) request.pageUrl = pageUrl;
|
|
61
68
|
const response = await this.makeRequest(
|
|
62
69
|
"/assignments",
|
|
63
70
|
"POST",
|
|
@@ -78,11 +85,6 @@ var BanditsClient = class {
|
|
|
78
85
|
config: assignment.treatment?.config || {}
|
|
79
86
|
};
|
|
80
87
|
}
|
|
81
|
-
/**
|
|
82
|
-
* Track a named event for session-based attribution (recommended)
|
|
83
|
-
* Events are matched to bandits by sessionId — no assignmentId needed
|
|
84
|
-
* Set immediate: true to fire synchronously (survives navigation)
|
|
85
|
-
*/
|
|
86
88
|
trackNamedEvent(event, opts) {
|
|
87
89
|
const request = {
|
|
88
90
|
name: event.name,
|
|
@@ -92,7 +94,7 @@ var BanditsClient = class {
|
|
|
92
94
|
sessionId: this.sessionId
|
|
93
95
|
};
|
|
94
96
|
if (opts?.immediate) {
|
|
95
|
-
const url = `${this.
|
|
97
|
+
const url = `${this.normalizeApiUrl()}/events`;
|
|
96
98
|
try {
|
|
97
99
|
void fetch(url, {
|
|
98
100
|
method: "POST",
|
|
@@ -117,7 +119,7 @@ var BanditsClient = class {
|
|
|
117
119
|
this.namedEventQueue = [];
|
|
118
120
|
if (namedEvents.length === 0) return;
|
|
119
121
|
try {
|
|
120
|
-
await fetch(`${this.
|
|
122
|
+
await fetch(`${this.normalizeApiUrl()}/events/batch`, {
|
|
121
123
|
method: "POST",
|
|
122
124
|
headers: {
|
|
123
125
|
"Authorization": `Bearer ${this.config.apiKey}`,
|
|
@@ -131,21 +133,6 @@ var BanditsClient = class {
|
|
|
131
133
|
throw error;
|
|
132
134
|
}
|
|
133
135
|
}
|
|
134
|
-
/**
|
|
135
|
-
* Link an anonymous user ID to a durable identity (e.g. email, account ID).
|
|
136
|
-
* All historical assignments and events for the anonymous ID are re-attributed
|
|
137
|
-
* to the identified user. Future calls to getAssignment() with either ID will
|
|
138
|
-
* resolve to the same assignment.
|
|
139
|
-
*
|
|
140
|
-
* Requires admin authentication — the client must be configured with a
|
|
141
|
-
* personal access token (PAT) instead of an API key, and companyId must
|
|
142
|
-
* be provided. This is a management operation, not an SDK client operation.
|
|
143
|
-
*
|
|
144
|
-
* @param anonymousId - The temporary/anonymous user ID (e.g. crypto.randomUUID())
|
|
145
|
-
* @param identifiedUserId - The permanent user ID (e.g. email, database ID)
|
|
146
|
-
* @param companyId - The company ID (required for PAT auth)
|
|
147
|
-
* @param mergeReason - Why the identification is happening
|
|
148
|
-
*/
|
|
149
136
|
async identify(anonymousId, identifiedUserId, companyId, mergeReason) {
|
|
150
137
|
const response = await this.makeRequest(
|
|
151
138
|
"/identify",
|
|
@@ -161,14 +148,12 @@ var BanditsClient = class {
|
|
|
161
148
|
throw new Error(response.error || "Failed to identify user");
|
|
162
149
|
}
|
|
163
150
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
* @param value - The payout value (e.g., purchase amount in dollars)
|
|
171
|
-
*/
|
|
151
|
+
async label(userId, labels) {
|
|
152
|
+
const response = await this.makeRequest("/labels", "POST", { userId, labels });
|
|
153
|
+
if (!response.success) {
|
|
154
|
+
throw new Error(response.error || "Failed to record labels");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
172
157
|
async trackFunnelReward(funnelId, userId, value) {
|
|
173
158
|
const response = await this.makeRequest(
|
|
174
159
|
`/funnels/${funnelId}/reward`,
|
|
@@ -186,6 +171,8 @@ var BanditsClient = class {
|
|
|
186
171
|
context,
|
|
187
172
|
sessionId: this.sessionId
|
|
188
173
|
};
|
|
174
|
+
const pageUrl = currentPageUrl();
|
|
175
|
+
if (pageUrl) request.pageUrl = pageUrl;
|
|
189
176
|
const response = await this.makeRequest("/bundles/assignment", "POST", request);
|
|
190
177
|
if (!response.success || !response.data) {
|
|
191
178
|
throw new Error(response.error || "Failed to get bundle assignment");
|
|
@@ -211,6 +198,13 @@ var BanditsClient = class {
|
|
|
211
198
|
generateId() {
|
|
212
199
|
return generateId();
|
|
213
200
|
}
|
|
201
|
+
normalizeApiUrl() {
|
|
202
|
+
let baseUrl = this.config.apiUrl.replace(/\/$/, "");
|
|
203
|
+
if (!baseUrl.endsWith("/api")) {
|
|
204
|
+
baseUrl = `${baseUrl}/api`;
|
|
205
|
+
}
|
|
206
|
+
return baseUrl;
|
|
207
|
+
}
|
|
214
208
|
getOrCreateSessionId() {
|
|
215
209
|
return getOrCreateSessionId({
|
|
216
210
|
storage: defaultSessionStorage(),
|
|
@@ -218,7 +212,7 @@ var BanditsClient = class {
|
|
|
218
212
|
});
|
|
219
213
|
}
|
|
220
214
|
async makeRequest(endpoint, method, body, extraHeaders) {
|
|
221
|
-
const url = `${this.
|
|
215
|
+
const url = `${this.normalizeApiUrl()}${endpoint}`;
|
|
222
216
|
const headers = {
|
|
223
217
|
"Authorization": `Bearer ${this.config.apiKey}`,
|
|
224
218
|
"Content-Type": "application/json",
|
|
@@ -267,6 +261,13 @@ var BanditsClient = class {
|
|
|
267
261
|
};
|
|
268
262
|
|
|
269
263
|
// ../shared/dist/types.js
|
|
264
|
+
var PLACEMENTS = [
|
|
265
|
+
"hero_headline",
|
|
266
|
+
"hero_subhead",
|
|
267
|
+
"cta_primary",
|
|
268
|
+
"banner_text",
|
|
269
|
+
"badge_text"
|
|
270
|
+
];
|
|
270
271
|
var ExperimentStatus;
|
|
271
272
|
(function(ExperimentStatus2) {
|
|
272
273
|
ExperimentStatus2["DRAFT"] = "draft";
|
|
@@ -280,6 +281,14 @@ var BanditAlgorithm;
|
|
|
280
281
|
BanditAlgorithm2["THOMPSON_SAMPLING"] = "thompson_sampling";
|
|
281
282
|
BanditAlgorithm2["CONTEXTUAL_LINEAR"] = "contextual_linear";
|
|
282
283
|
})(BanditAlgorithm || (BanditAlgorithm = {}));
|
|
284
|
+
var Posterior;
|
|
285
|
+
(function(Posterior2) {
|
|
286
|
+
Posterior2["BETA"] = "beta";
|
|
287
|
+
Posterior2["NORMAL_GAMMA"] = "normal_gamma";
|
|
288
|
+
})(Posterior || (Posterior = {}));
|
|
289
|
+
function normalizePosterior(posterior) {
|
|
290
|
+
return posterior === Posterior.NORMAL_GAMMA ? Posterior.NORMAL_GAMMA : Posterior.BETA;
|
|
291
|
+
}
|
|
283
292
|
var ForgettingMode;
|
|
284
293
|
(function(ForgettingMode2) {
|
|
285
294
|
ForgettingMode2["NONE"] = "none";
|
|
@@ -450,6 +459,251 @@ function buildSubdomainUrl(subdomain, baseDomain, path = "", protocol = "https")
|
|
|
450
459
|
return path ? `${url}${path.startsWith("/") ? path : "/" + path}` : url;
|
|
451
460
|
}
|
|
452
461
|
|
|
462
|
+
// ../shared/dist/email-token.js
|
|
463
|
+
var EMAIL_TOKEN_PARAM = "bt";
|
|
464
|
+
var encoder = new TextEncoder();
|
|
465
|
+
function base64UrlEncode(bytes) {
|
|
466
|
+
let binary = "";
|
|
467
|
+
for (const byte of bytes)
|
|
468
|
+
binary += String.fromCharCode(byte);
|
|
469
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
470
|
+
}
|
|
471
|
+
function base64UrlDecode(value) {
|
|
472
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
473
|
+
const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
474
|
+
const bytes = new Uint8Array(binary.length);
|
|
475
|
+
for (let i = 0; i < binary.length; i++)
|
|
476
|
+
bytes[i] = binary.charCodeAt(i);
|
|
477
|
+
return bytes;
|
|
478
|
+
}
|
|
479
|
+
async function hmac(secret, message) {
|
|
480
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
481
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
|
|
482
|
+
}
|
|
483
|
+
function timingSafeEqual(a, b) {
|
|
484
|
+
if (a.length !== b.length)
|
|
485
|
+
return false;
|
|
486
|
+
let diff = 0;
|
|
487
|
+
for (let i = 0; i < a.length; i++)
|
|
488
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
489
|
+
return diff === 0;
|
|
490
|
+
}
|
|
491
|
+
async function hashRecipient(recipientId, companySecret) {
|
|
492
|
+
const digest = await hmac(companySecret, `recipient:${recipientId}`);
|
|
493
|
+
return base64UrlEncode(digest.slice(0, 16));
|
|
494
|
+
}
|
|
495
|
+
async function mintEmailToken(payload, key) {
|
|
496
|
+
const body = base64UrlEncode(encoder.encode(JSON.stringify({
|
|
497
|
+
c: payload.c,
|
|
498
|
+
r: payload.r,
|
|
499
|
+
k: payload.k,
|
|
500
|
+
l: payload.l,
|
|
501
|
+
v: key.version
|
|
502
|
+
})));
|
|
503
|
+
const signature = base64UrlEncode(await hmac(key.secret, body));
|
|
504
|
+
return `${body}.${signature}`;
|
|
505
|
+
}
|
|
506
|
+
async function verifyEmailToken(token, keys) {
|
|
507
|
+
const parts = token.split(".");
|
|
508
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
509
|
+
return { valid: false, reason: "malformed" };
|
|
510
|
+
}
|
|
511
|
+
const [body, signature] = parts;
|
|
512
|
+
let payload;
|
|
513
|
+
try {
|
|
514
|
+
payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
|
|
515
|
+
} catch {
|
|
516
|
+
return { valid: false, reason: "malformed" };
|
|
517
|
+
}
|
|
518
|
+
if (!payload || typeof payload.c !== "string" || typeof payload.r !== "string" || !Array.isArray(payload.k) || !payload.k.every((arm) => Number.isInteger(arm) && arm >= 0) || !Number.isInteger(payload.l) || payload.l < 0 || !Number.isInteger(payload.v)) {
|
|
519
|
+
return { valid: false, reason: "malformed" };
|
|
520
|
+
}
|
|
521
|
+
const key = keys.find((candidate) => candidate.version === payload.v);
|
|
522
|
+
if (!key)
|
|
523
|
+
return { valid: false, reason: "unknown_key" };
|
|
524
|
+
const expected = base64UrlEncode(await hmac(key.secret, body));
|
|
525
|
+
if (!timingSafeEqual(expected, signature)) {
|
|
526
|
+
return { valid: false, reason: "bad_signature" };
|
|
527
|
+
}
|
|
528
|
+
return { valid: true, payload };
|
|
529
|
+
}
|
|
530
|
+
function appendEmailToken(url, token) {
|
|
531
|
+
const hashIndex = url.indexOf("#");
|
|
532
|
+
const fragment = hashIndex === -1 ? "" : url.slice(hashIndex);
|
|
533
|
+
const base = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
534
|
+
const separator = base.includes("?") ? "&" : "?";
|
|
535
|
+
return `${base}${separator}${EMAIL_TOKEN_PARAM}=${encodeURIComponent(token)}${fragment}`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ../shared/dist/email-plan.js
|
|
539
|
+
async function uniformFor(salt, recipientId, slotIndex) {
|
|
540
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(salt), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
541
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${recipientId}:${slotIndex}`)));
|
|
542
|
+
let value = 0;
|
|
543
|
+
for (let i = 0; i < 6; i++)
|
|
544
|
+
value = value * 256 + mac[i];
|
|
545
|
+
return value / 2 ** 48;
|
|
546
|
+
}
|
|
547
|
+
function pickArm(probabilities, uniform) {
|
|
548
|
+
if (probabilities.length === 0)
|
|
549
|
+
return -1;
|
|
550
|
+
const total = probabilities.reduce((sum, p) => sum + Math.max(p, 0), 0);
|
|
551
|
+
if (total <= 0) {
|
|
552
|
+
return Math.min(probabilities.length - 1, Math.floor(uniform * probabilities.length));
|
|
553
|
+
}
|
|
554
|
+
const target = uniform * total;
|
|
555
|
+
let cumulative = 0;
|
|
556
|
+
for (let i = 0; i < probabilities.length; i++) {
|
|
557
|
+
cumulative += Math.max(probabilities[i], 0);
|
|
558
|
+
if (target < cumulative)
|
|
559
|
+
return i;
|
|
560
|
+
}
|
|
561
|
+
return probabilities.length - 1;
|
|
562
|
+
}
|
|
563
|
+
async function sampleCombination(plan, recipientId) {
|
|
564
|
+
const combination = [];
|
|
565
|
+
for (let i = 0; i < plan.slots.length; i++) {
|
|
566
|
+
const slot = plan.slots[i];
|
|
567
|
+
const uniform = await uniformFor(plan.salt, recipientId, i);
|
|
568
|
+
const chosen = pickArm(slot.probabilities, uniform);
|
|
569
|
+
combination.push(chosen === -1 ? 0 : slot.arms[chosen]?.armIndex ?? 0);
|
|
570
|
+
}
|
|
571
|
+
return combination;
|
|
572
|
+
}
|
|
573
|
+
async function impressionManifest(plan, recipientIds) {
|
|
574
|
+
const counts = /* @__PURE__ */ new Map();
|
|
575
|
+
for (const recipientId of recipientIds) {
|
|
576
|
+
for (let i = 0; i < plan.slots.length; i++) {
|
|
577
|
+
const slot = plan.slots[i];
|
|
578
|
+
const uniform = await uniformFor(plan.salt, recipientId, i);
|
|
579
|
+
const chosen = pickArm(slot.probabilities, uniform);
|
|
580
|
+
const arm = slot.arms[chosen === -1 ? 0 : chosen];
|
|
581
|
+
if (!arm)
|
|
582
|
+
continue;
|
|
583
|
+
const key = `${slot.experimentId}:${arm.treatmentId}`;
|
|
584
|
+
const entry = counts.get(key);
|
|
585
|
+
if (entry)
|
|
586
|
+
entry.count++;
|
|
587
|
+
else
|
|
588
|
+
counts.set(key, {
|
|
589
|
+
experimentId: slot.experimentId,
|
|
590
|
+
treatmentId: arm.treatmentId,
|
|
591
|
+
count: 1
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return Array.from(counts.values());
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ../shared/dist/email-blocks.js
|
|
599
|
+
function isSlotBinding(value) {
|
|
600
|
+
return typeof value === "object" && value !== null && typeof value.$slot === "string";
|
|
601
|
+
}
|
|
602
|
+
var CONTENT_WIDTH = 600;
|
|
603
|
+
function escapeHtml(value) {
|
|
604
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
605
|
+
}
|
|
606
|
+
function resolve(value, slots, missing) {
|
|
607
|
+
if (value === void 0)
|
|
608
|
+
return "";
|
|
609
|
+
if (!isSlotBinding(value))
|
|
610
|
+
return value;
|
|
611
|
+
const resolved = slots[value.$slot];
|
|
612
|
+
if (resolved === void 0) {
|
|
613
|
+
missing.push(value.$slot);
|
|
614
|
+
return "";
|
|
615
|
+
}
|
|
616
|
+
return resolved;
|
|
617
|
+
}
|
|
618
|
+
function extractDocumentSlots(document) {
|
|
619
|
+
const names = [];
|
|
620
|
+
const seen = /* @__PURE__ */ new Set();
|
|
621
|
+
const visit = (value) => {
|
|
622
|
+
if (isSlotBinding(value)) {
|
|
623
|
+
if (!seen.has(value.$slot)) {
|
|
624
|
+
seen.add(value.$slot);
|
|
625
|
+
names.push(value.$slot);
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (Array.isArray(value))
|
|
630
|
+
value.forEach(visit);
|
|
631
|
+
else if (typeof value === "object" && value !== null)
|
|
632
|
+
Object.values(value).forEach(visit);
|
|
633
|
+
};
|
|
634
|
+
visit(document.subject);
|
|
635
|
+
visit(document.preheader);
|
|
636
|
+
document.blocks.forEach(visit);
|
|
637
|
+
return names;
|
|
638
|
+
}
|
|
639
|
+
function renderBlock(block, slots, missing) {
|
|
640
|
+
const align = block.align ?? "left";
|
|
641
|
+
switch (block.type) {
|
|
642
|
+
case "heading": {
|
|
643
|
+
const size = block.level === 3 ? 18 : block.level === 2 ? 22 : 28;
|
|
644
|
+
const color = escapeHtml(resolve(block.color, slots, missing) || "#111111");
|
|
645
|
+
return `<tr><td style="padding:12px 24px;text-align:${align};font-family:Arial,Helvetica,sans-serif;font-size:${size}px;line-height:1.3;font-weight:bold;color:${color}">${escapeHtml(resolve(block.text, slots, missing))}</td></tr>`;
|
|
646
|
+
}
|
|
647
|
+
case "text": {
|
|
648
|
+
const color = escapeHtml(resolve(block.color, slots, missing) || "#333333");
|
|
649
|
+
return `<tr><td style="padding:8px 24px;text-align:${align};font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:${color}">${escapeHtml(resolve(block.text, slots, missing))}</td></tr>`;
|
|
650
|
+
}
|
|
651
|
+
case "button": {
|
|
652
|
+
const href = escapeHtml(resolve(block.href, slots, missing));
|
|
653
|
+
const label = escapeHtml(resolve(block.text, slots, missing));
|
|
654
|
+
const bg = escapeHtml(resolve(block.backgroundColor, slots, missing) || "#1a73e8");
|
|
655
|
+
const fg = escapeHtml(resolve(block.textColor, slots, missing) || "#ffffff");
|
|
656
|
+
return `<tr><td style="padding:16px 24px;text-align:${align}">
|
|
657
|
+
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${href}" style="height:44px;v-text-anchor:middle;width:220px;" arcsize="12%" strokecolor="${bg}" fillcolor="${bg}"><w:anchorlock/><center style="color:${fg};font-family:Arial,sans-serif;font-size:16px;font-weight:bold;">${label}</center></v:roundrect><![endif]-->
|
|
658
|
+
<!--[if !mso]><!--><a href="${href}" style="display:inline-block;padding:13px 28px;background-color:${bg};color:${fg};font-family:Arial,Helvetica,sans-serif;font-size:16px;font-weight:bold;text-decoration:none;border-radius:6px">${label}</a><!--<![endif]-->
|
|
659
|
+
</td></tr>`;
|
|
660
|
+
}
|
|
661
|
+
case "image": {
|
|
662
|
+
const src = escapeHtml(resolve(block.src, slots, missing));
|
|
663
|
+
const alt = escapeHtml(resolve(block.alt, slots, missing));
|
|
664
|
+
const width = block.width ?? CONTENT_WIDTH;
|
|
665
|
+
const img = `<img src="${src}" alt="${alt}" width="${width}" style="display:block;border:0;outline:none;text-decoration:none;max-width:100%;height:auto" />`;
|
|
666
|
+
const href = block.href ? escapeHtml(resolve(block.href, slots, missing)) : "";
|
|
667
|
+
return `<tr><td style="padding:0;text-align:center">${href ? `<a href="${href}">${img}</a>` : img}</td></tr>`;
|
|
668
|
+
}
|
|
669
|
+
case "divider":
|
|
670
|
+
return `<tr><td style="padding:12px 24px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-top:1px solid ${escapeHtml(block.color ?? "#e5e5e5")};font-size:0;line-height:0"> </td></tr></table></td></tr>`;
|
|
671
|
+
case "spacer":
|
|
672
|
+
return `<tr><td style="height:${block.height ?? 24}px;font-size:0;line-height:0"> </td></tr>`;
|
|
673
|
+
default:
|
|
674
|
+
return "";
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function renderEmailDocument(document, slots) {
|
|
678
|
+
const missingSlots = [];
|
|
679
|
+
const width = document.contentWidth ?? CONTENT_WIDTH;
|
|
680
|
+
const background = escapeHtml(document.backgroundColor ?? "#f4f4f5");
|
|
681
|
+
const subject = resolve(document.subject, slots, missingSlots);
|
|
682
|
+
const preheader = resolve(document.preheader, slots, missingSlots);
|
|
683
|
+
const body = document.blocks.map((block) => renderBlock(block, slots, missingSlots)).join("\n");
|
|
684
|
+
const html = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
685
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
686
|
+
<head>
|
|
687
|
+
<meta charset="utf-8" />
|
|
688
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
689
|
+
<meta name="x-apple-disable-message-reformatting" />
|
|
690
|
+
<title>${escapeHtml(subject)}</title>
|
|
691
|
+
<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
|
|
692
|
+
</head>
|
|
693
|
+
<body style="margin:0;padding:0;background-color:${background}">
|
|
694
|
+
<div style="display:none;font-size:0;line-height:0;max-height:0;overflow:hidden;mso-hide:all">${escapeHtml(preheader)}</div>
|
|
695
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:${background}">
|
|
696
|
+
<tr><td align="center" style="padding:24px 12px">
|
|
697
|
+
<table role="presentation" width="${width}" cellpadding="0" cellspacing="0" border="0" style="width:${width}px;max-width:100%;background-color:#ffffff;border-radius:8px">
|
|
698
|
+
${body}
|
|
699
|
+
</table>
|
|
700
|
+
</td></tr>
|
|
701
|
+
</table>
|
|
702
|
+
</body>
|
|
703
|
+
</html>`;
|
|
704
|
+
return { subject, html, missingSlots };
|
|
705
|
+
}
|
|
706
|
+
|
|
453
707
|
// src/snapshot-cache.ts
|
|
454
708
|
var STORAGE_KEY = "bandits_snapshot";
|
|
455
709
|
var REPORT_QUEUE_KEY = "bandits_pending_reports";
|
|
@@ -499,16 +753,6 @@ var SnapshotClient = class {
|
|
|
499
753
|
this.snapshot = this.readSnapshot();
|
|
500
754
|
}
|
|
501
755
|
}
|
|
502
|
-
/**
|
|
503
|
-
* Decide an arm with no network call, or return null.
|
|
504
|
-
*
|
|
505
|
-
* Null means "this caller must fall back" - a cold cache, an expired one, an
|
|
506
|
-
* unknown experiment, or one whose state cannot be served from a static
|
|
507
|
-
* document. Returning null rather than the control is deliberate: the caller
|
|
508
|
-
* renders control itself, and no impression is reported for it. Reporting a
|
|
509
|
-
* forced-control impression would inflate the control arm's trials with
|
|
510
|
-
* decisions the bandit never made.
|
|
511
|
-
*/
|
|
512
756
|
getAssignmentSync(experimentId, userId) {
|
|
513
757
|
const snapshot = this.freshSnapshot();
|
|
514
758
|
if (!snapshot) return null;
|
|
@@ -535,7 +779,6 @@ var SnapshotClient = class {
|
|
|
535
779
|
decisions[key] = {
|
|
536
780
|
clientAssignmentId,
|
|
537
781
|
treatmentId: treatment.id,
|
|
538
|
-
// expiresAt is milliseconds here; assignmentTtl arrives in seconds.
|
|
539
782
|
expiresAt: this.now() + ttlSeconds * 1e3
|
|
540
783
|
};
|
|
541
784
|
this.writeDecisions(decisions);
|
|
@@ -554,7 +797,6 @@ var SnapshotClient = class {
|
|
|
554
797
|
config: treatment.config
|
|
555
798
|
};
|
|
556
799
|
}
|
|
557
|
-
/** Fetch a fresh snapshot and cache it. Safe to call on every pageview. */
|
|
558
800
|
async refresh() {
|
|
559
801
|
const response = await this.fetchImpl(`${this.config.apiUrl}/snapshot`, {
|
|
560
802
|
method: "GET",
|
|
@@ -565,14 +807,6 @@ var SnapshotClient = class {
|
|
|
565
807
|
this.snapshot = snapshot;
|
|
566
808
|
this.persistSnapshot(snapshot);
|
|
567
809
|
}
|
|
568
|
-
/**
|
|
569
|
-
* Post queued impressions.
|
|
570
|
-
*
|
|
571
|
-
* The queue is only cleared on success. A failed flush leaves the reports in
|
|
572
|
-
* storage, so a pageview that happened offline is still counted when the
|
|
573
|
-
* next one succeeds - the trial really did occur, and dropping it would bias
|
|
574
|
-
* the arm's denominator downward.
|
|
575
|
-
*/
|
|
576
810
|
async flush() {
|
|
577
811
|
if (this.inFlight) return this.inFlight;
|
|
578
812
|
this.inFlight = this.doFlush().finally(() => {
|
|
@@ -600,14 +834,12 @@ var SnapshotClient = class {
|
|
|
600
834
|
this.queue = this.queue.slice(reports.length);
|
|
601
835
|
this.persistQueue();
|
|
602
836
|
}
|
|
603
|
-
/** The session id these reports are attributed under. */
|
|
604
837
|
getSessionId() {
|
|
605
838
|
return this.sessionId;
|
|
606
839
|
}
|
|
607
840
|
pendingReportCount() {
|
|
608
841
|
return this.queue.length;
|
|
609
842
|
}
|
|
610
|
-
/** The cached snapshot, or null when absent or past its ttl. */
|
|
611
843
|
freshSnapshot() {
|
|
612
844
|
if (!this.snapshot) return null;
|
|
613
845
|
if (this.snapshot.ttl === 0) return null;
|
|
@@ -640,7 +872,6 @@ var SnapshotClient = class {
|
|
|
640
872
|
return [];
|
|
641
873
|
}
|
|
642
874
|
}
|
|
643
|
-
/** Stored decisions, pruned of expired entries. */
|
|
644
875
|
readDecisions() {
|
|
645
876
|
if (!this.storage) return {};
|
|
646
877
|
try {
|
|
@@ -672,6 +903,198 @@ var SnapshotClient = class {
|
|
|
672
903
|
}
|
|
673
904
|
}
|
|
674
905
|
};
|
|
906
|
+
|
|
907
|
+
// src/email-links.ts
|
|
908
|
+
var UNTRACKABLE_SCHEME = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
|
|
909
|
+
var NOTRACK_ATTRIBUTE = "data-bandit-notrack";
|
|
910
|
+
function isTrackable(url) {
|
|
911
|
+
const trimmed = url.trim();
|
|
912
|
+
if (!trimmed || UNTRACKABLE_SCHEME.test(trimmed)) return false;
|
|
913
|
+
return /^https?:\/\//i.test(trimmed);
|
|
914
|
+
}
|
|
915
|
+
function appendToken(url, token) {
|
|
916
|
+
const hashIndex = url.indexOf("#");
|
|
917
|
+
const fragment = hashIndex === -1 ? "" : url.slice(hashIndex);
|
|
918
|
+
const base = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
919
|
+
const separator = base.includes("?") ? base.includes("&") ? "&" : "&" : "?";
|
|
920
|
+
return `${base}${separator}${EMAIL_TOKEN_PARAM}=${token}${fragment}`;
|
|
921
|
+
}
|
|
922
|
+
function scanAnchors(html) {
|
|
923
|
+
const anchors = [];
|
|
924
|
+
let i = 0;
|
|
925
|
+
while (i < html.length) {
|
|
926
|
+
const lt = html.indexOf("<", i);
|
|
927
|
+
if (lt === -1) break;
|
|
928
|
+
if (html.startsWith("<!--", lt)) {
|
|
929
|
+
if (html.startsWith("<!--[if", lt) || html.startsWith("<!--<![endif]", lt)) {
|
|
930
|
+
const close = html.indexOf(">", lt);
|
|
931
|
+
i = close === -1 ? html.length : close + 1;
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
const end = html.indexOf("-->", lt);
|
|
935
|
+
i = end === -1 ? html.length : end + 3;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
const rawText = /^<(script|style)\b/i.exec(html.slice(lt, lt + 8));
|
|
939
|
+
if (rawText) {
|
|
940
|
+
const closing = new RegExp(`</${rawText[1]}\\s*>`, "i");
|
|
941
|
+
const rest = html.slice(lt);
|
|
942
|
+
const match = closing.exec(rest);
|
|
943
|
+
i = match ? lt + match.index + match[0].length : html.length;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
const isLinkElement = /^<a[\s/>]/i.test(html.slice(lt, lt + 3)) || /^<v:roundrect[\s/>]/i.test(html.slice(lt, lt + 13));
|
|
947
|
+
if (!isLinkElement) {
|
|
948
|
+
i = lt + 1;
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
const tagEnd = findTagEnd(html, lt);
|
|
952
|
+
if (tagEnd === -1) break;
|
|
953
|
+
const tag = html.slice(lt, tagEnd);
|
|
954
|
+
if (!new RegExp(NOTRACK_ATTRIBUTE, "i").test(tag)) {
|
|
955
|
+
const href = findHref(html, lt, tagEnd);
|
|
956
|
+
if (href && isTrackable(href.url)) anchors.push(href);
|
|
957
|
+
}
|
|
958
|
+
i = tagEnd;
|
|
959
|
+
}
|
|
960
|
+
return anchors;
|
|
961
|
+
}
|
|
962
|
+
function findTagEnd(html, start) {
|
|
963
|
+
let quote = null;
|
|
964
|
+
for (let i = start + 1; i < html.length; i++) {
|
|
965
|
+
const ch = html[i];
|
|
966
|
+
if (quote) {
|
|
967
|
+
if (ch === quote) quote = null;
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
if (ch === '"' || ch === "'") {
|
|
971
|
+
quote = ch;
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
if (ch === ">") return i + 1;
|
|
975
|
+
}
|
|
976
|
+
return -1;
|
|
977
|
+
}
|
|
978
|
+
function findHref(html, tagStart, tagEnd) {
|
|
979
|
+
const tag = html.slice(tagStart, tagEnd);
|
|
980
|
+
const match = /\shref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag);
|
|
981
|
+
if (!match) return null;
|
|
982
|
+
const quoted = match[2] !== void 0 || match[3] !== void 0;
|
|
983
|
+
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
984
|
+
const valueStart = tagStart + match.index + match[0].indexOf(match[1]) + (quoted ? 1 : 0);
|
|
985
|
+
return { valueStart, valueEnd: valueStart + value.length, url: value };
|
|
986
|
+
}
|
|
987
|
+
function extractEmailLinks(html) {
|
|
988
|
+
const seen = /* @__PURE__ */ new Set();
|
|
989
|
+
const urls = [];
|
|
990
|
+
for (const anchor of scanAnchors(html)) {
|
|
991
|
+
if (seen.has(anchor.url)) continue;
|
|
992
|
+
seen.add(anchor.url);
|
|
993
|
+
urls.push(anchor.url);
|
|
994
|
+
}
|
|
995
|
+
return urls;
|
|
996
|
+
}
|
|
997
|
+
function rewriteEmailLinks(html, options) {
|
|
998
|
+
const { linkTable, tokens } = options;
|
|
999
|
+
const indexOfUrl = /* @__PURE__ */ new Map();
|
|
1000
|
+
linkTable.forEach((url, index) => {
|
|
1001
|
+
if (!indexOfUrl.has(url)) indexOfUrl.set(url, index);
|
|
1002
|
+
});
|
|
1003
|
+
const anchors = scanAnchors(html);
|
|
1004
|
+
const untracked = [];
|
|
1005
|
+
let rewritten = 0;
|
|
1006
|
+
let output = html;
|
|
1007
|
+
for (let i = anchors.length - 1; i >= 0; i--) {
|
|
1008
|
+
const anchor = anchors[i];
|
|
1009
|
+
const index = indexOfUrl.get(anchor.url);
|
|
1010
|
+
if (index === void 0 || tokens[index] === void 0) {
|
|
1011
|
+
untracked.push(anchor.url);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
output = output.slice(0, anchor.valueStart) + appendToken(anchor.url, tokens[index]) + output.slice(anchor.valueEnd);
|
|
1015
|
+
rewritten++;
|
|
1016
|
+
}
|
|
1017
|
+
return { html: output, rewritten, untracked: untracked.reverse() };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// src/email.ts
|
|
1021
|
+
var SLOT_PATTERN = /\{\{\s*bandit\.([a-zA-Z0-9_-]+)\s*\}\}/g;
|
|
1022
|
+
function renderEmail(templateHtml, options) {
|
|
1023
|
+
const missingSlots = [];
|
|
1024
|
+
const substituted = templateHtml.replace(SLOT_PATTERN, (match, name) => {
|
|
1025
|
+
const value = options.slots[name];
|
|
1026
|
+
if (value === void 0) {
|
|
1027
|
+
missingSlots.push(name);
|
|
1028
|
+
return match;
|
|
1029
|
+
}
|
|
1030
|
+
return value;
|
|
1031
|
+
});
|
|
1032
|
+
const { html, rewritten, untracked } = rewriteEmailLinks(substituted, {
|
|
1033
|
+
linkTable: options.linkTable,
|
|
1034
|
+
tokens: options.tokens
|
|
1035
|
+
});
|
|
1036
|
+
return { html, rewritten, untracked, missingSlots };
|
|
1037
|
+
}
|
|
1038
|
+
function extractSlotNames(templateHtml) {
|
|
1039
|
+
const names = /* @__PURE__ */ new Set();
|
|
1040
|
+
for (const match of templateHtml.matchAll(SLOT_PATTERN)) names.add(match[1]);
|
|
1041
|
+
return [...names];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// src/email-plan.ts
|
|
1045
|
+
async function renderFromPlan(plan, recipientId, templateHtml, companySecret) {
|
|
1046
|
+
const slots = {};
|
|
1047
|
+
const combination = [];
|
|
1048
|
+
for (let i = 0; i < plan.slots.length; i++) {
|
|
1049
|
+
const slot = plan.slots[i];
|
|
1050
|
+
const chosen = pickArm(slot.probabilities, await uniformFor(plan.salt, recipientId, i));
|
|
1051
|
+
const index = chosen === -1 ? 0 : chosen;
|
|
1052
|
+
combination.push(slot.arms[index]?.armIndex ?? 0);
|
|
1053
|
+
const config = slot.configs[index];
|
|
1054
|
+
slots[slot.name] = typeof config === "string" ? config : config?.content ?? config?.text ?? "";
|
|
1055
|
+
}
|
|
1056
|
+
const recipientHash = await hashRecipient(recipientId, companySecret);
|
|
1057
|
+
const tokens = await Promise.all(
|
|
1058
|
+
plan.linkTable.map(
|
|
1059
|
+
(_, linkIndex) => mintEmailToken(
|
|
1060
|
+
{ c: plan.campaignId, r: recipientHash, k: combination, l: linkIndex },
|
|
1061
|
+
plan.signingKey
|
|
1062
|
+
)
|
|
1063
|
+
)
|
|
1064
|
+
);
|
|
1065
|
+
const rendered = renderEmail(templateHtml, {
|
|
1066
|
+
slots,
|
|
1067
|
+
linkTable: plan.linkTable,
|
|
1068
|
+
tokens
|
|
1069
|
+
});
|
|
1070
|
+
return { ...rendered, combination, tokens };
|
|
1071
|
+
}
|
|
1072
|
+
function shouldRefreshPlan(plan, recipientsRenderedSincePlan, now = Date.now()) {
|
|
1073
|
+
return recipientsRenderedSincePlan >= plan.refreshEvery || now >= plan.expiresAt;
|
|
1074
|
+
}
|
|
1075
|
+
async function reportPlanImpressions(plan, recipientIds, options) {
|
|
1076
|
+
const counts = await impressionManifest(plan, recipientIds);
|
|
1077
|
+
if (counts.length === 0) return { recorded: false, alreadyRecorded: false };
|
|
1078
|
+
const response = await fetch(`${options.apiUrl}/api/email/impressions`, {
|
|
1079
|
+
method: "POST",
|
|
1080
|
+
headers: { "Content-Type": "application/json", "x-api-key": options.apiKey },
|
|
1081
|
+
body: JSON.stringify({
|
|
1082
|
+
campaignId: plan.campaignId,
|
|
1083
|
+
batchKey: options.batchKey ?? await batchKeyFor(plan.campaignId, recipientIds),
|
|
1084
|
+
counts
|
|
1085
|
+
})
|
|
1086
|
+
});
|
|
1087
|
+
if (!response.ok) {
|
|
1088
|
+
throw new Error(`Failed to report impressions: ${response.status}`);
|
|
1089
|
+
}
|
|
1090
|
+
const body = await response.json();
|
|
1091
|
+
return body.data ?? { recorded: false, alreadyRecorded: false };
|
|
1092
|
+
}
|
|
1093
|
+
async function batchKeyFor(campaignId, recipientIds) {
|
|
1094
|
+
const material = `${campaignId}|${[...recipientIds].sort().join(",")}`;
|
|
1095
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
|
|
1096
|
+
return Array.from(new Uint8Array(digest).slice(0, 16)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1097
|
+
}
|
|
675
1098
|
export {
|
|
676
1099
|
BanditAlgorithm,
|
|
677
1100
|
BanditsClient as BanditClient,
|
|
@@ -680,27 +1103,49 @@ export {
|
|
|
680
1103
|
DEFAULT_HALF_LIFE_MS,
|
|
681
1104
|
DEFAULT_SESSION_TTL_MS,
|
|
682
1105
|
DEFAULT_WINDOW_MS,
|
|
1106
|
+
EMAIL_TOKEN_PARAM,
|
|
683
1107
|
ExperimentStatus,
|
|
684
1108
|
ForgettingMode,
|
|
685
1109
|
FunnelStatus,
|
|
1110
|
+
PLACEMENTS,
|
|
1111
|
+
Posterior,
|
|
686
1112
|
SESSION_CREATED_AT_KEY,
|
|
687
1113
|
SESSION_ID_KEY,
|
|
688
1114
|
STATIONARY,
|
|
689
1115
|
SnapshotClient,
|
|
1116
|
+
appendEmailToken,
|
|
690
1117
|
buildSubdomainUrl,
|
|
691
1118
|
calculateConfidenceInterval,
|
|
1119
|
+
extractDocumentSlots,
|
|
1120
|
+
extractEmailLinks,
|
|
1121
|
+
extractSlotNames,
|
|
692
1122
|
extractSubdomain,
|
|
693
1123
|
generateId2 as generateId,
|
|
694
1124
|
generateSubdomain,
|
|
695
1125
|
getContentTypeCategory,
|
|
696
1126
|
getOrCreateSessionId,
|
|
1127
|
+
hashRecipient,
|
|
1128
|
+
impressionManifest,
|
|
1129
|
+
isSlotBinding,
|
|
697
1130
|
isValidEmail,
|
|
698
1131
|
isValidSubdomain,
|
|
1132
|
+
mintEmailToken,
|
|
699
1133
|
normalizeAlgorithm,
|
|
1134
|
+
normalizePosterior,
|
|
700
1135
|
normalizeTreatmentWeights,
|
|
1136
|
+
pickArm,
|
|
701
1137
|
pickTreatment,
|
|
1138
|
+
renderEmail,
|
|
1139
|
+
renderEmailDocument,
|
|
1140
|
+
renderFromPlan,
|
|
1141
|
+
reportPlanImpressions,
|
|
702
1142
|
resolveForgetting,
|
|
1143
|
+
rewriteEmailLinks,
|
|
1144
|
+
sampleCombination,
|
|
703
1145
|
sanitizeMetadata,
|
|
704
|
-
|
|
1146
|
+
shouldRefreshPlan,
|
|
1147
|
+
uniformFor,
|
|
1148
|
+
validateTreatmentWeights,
|
|
1149
|
+
verifyEmailToken
|
|
705
1150
|
};
|
|
706
1151
|
//# sourceMappingURL=index.js.map
|