@runbandit/sdk 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/index.d.ts +584 -1
- package/dist/index.js +502 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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 = [];
|
|
@@ -58,6 +64,8 @@ var BanditsClient = class {
|
|
|
58
64
|
context,
|
|
59
65
|
sessionId: this.sessionId
|
|
60
66
|
};
|
|
67
|
+
const pageUrl = currentPageUrl();
|
|
68
|
+
if (pageUrl) request.pageUrl = pageUrl;
|
|
61
69
|
const response = await this.makeRequest(
|
|
62
70
|
"/assignments",
|
|
63
71
|
"POST",
|
|
@@ -92,7 +100,7 @@ var BanditsClient = class {
|
|
|
92
100
|
sessionId: this.sessionId
|
|
93
101
|
};
|
|
94
102
|
if (opts?.immediate) {
|
|
95
|
-
const url = `${this.
|
|
103
|
+
const url = `${this.normalizeApiUrl()}/events`;
|
|
96
104
|
try {
|
|
97
105
|
void fetch(url, {
|
|
98
106
|
method: "POST",
|
|
@@ -117,7 +125,7 @@ var BanditsClient = class {
|
|
|
117
125
|
this.namedEventQueue = [];
|
|
118
126
|
if (namedEvents.length === 0) return;
|
|
119
127
|
try {
|
|
120
|
-
await fetch(`${this.
|
|
128
|
+
await fetch(`${this.normalizeApiUrl()}/events/batch`, {
|
|
121
129
|
method: "POST",
|
|
122
130
|
headers: {
|
|
123
131
|
"Authorization": `Bearer ${this.config.apiKey}`,
|
|
@@ -186,6 +194,8 @@ var BanditsClient = class {
|
|
|
186
194
|
context,
|
|
187
195
|
sessionId: this.sessionId
|
|
188
196
|
};
|
|
197
|
+
const pageUrl = currentPageUrl();
|
|
198
|
+
if (pageUrl) request.pageUrl = pageUrl;
|
|
189
199
|
const response = await this.makeRequest("/bundles/assignment", "POST", request);
|
|
190
200
|
if (!response.success || !response.data) {
|
|
191
201
|
throw new Error(response.error || "Failed to get bundle assignment");
|
|
@@ -211,6 +221,17 @@ var BanditsClient = class {
|
|
|
211
221
|
generateId() {
|
|
212
222
|
return generateId();
|
|
213
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Normalize the apiUrl by adding /api prefix if not present.
|
|
226
|
+
* Supports both 'https://runbandit.com' and 'https://runbandit.com/api'.
|
|
227
|
+
*/
|
|
228
|
+
normalizeApiUrl() {
|
|
229
|
+
let baseUrl = this.config.apiUrl.replace(/\/$/, "");
|
|
230
|
+
if (!baseUrl.endsWith("/api")) {
|
|
231
|
+
baseUrl = `${baseUrl}/api`;
|
|
232
|
+
}
|
|
233
|
+
return baseUrl;
|
|
234
|
+
}
|
|
214
235
|
getOrCreateSessionId() {
|
|
215
236
|
return getOrCreateSessionId({
|
|
216
237
|
storage: defaultSessionStorage(),
|
|
@@ -218,7 +239,7 @@ var BanditsClient = class {
|
|
|
218
239
|
});
|
|
219
240
|
}
|
|
220
241
|
async makeRequest(endpoint, method, body, extraHeaders) {
|
|
221
|
-
const url = `${this.
|
|
242
|
+
const url = `${this.normalizeApiUrl()}${endpoint}`;
|
|
222
243
|
const headers = {
|
|
223
244
|
"Authorization": `Bearer ${this.config.apiKey}`,
|
|
224
245
|
"Content-Type": "application/json",
|
|
@@ -267,6 +288,13 @@ var BanditsClient = class {
|
|
|
267
288
|
};
|
|
268
289
|
|
|
269
290
|
// ../shared/dist/types.js
|
|
291
|
+
var PLACEMENTS = [
|
|
292
|
+
"hero_headline",
|
|
293
|
+
"hero_subhead",
|
|
294
|
+
"cta_primary",
|
|
295
|
+
"banner_text",
|
|
296
|
+
"badge_text"
|
|
297
|
+
];
|
|
270
298
|
var ExperimentStatus;
|
|
271
299
|
(function(ExperimentStatus2) {
|
|
272
300
|
ExperimentStatus2["DRAFT"] = "draft";
|
|
@@ -280,6 +308,14 @@ var BanditAlgorithm;
|
|
|
280
308
|
BanditAlgorithm2["THOMPSON_SAMPLING"] = "thompson_sampling";
|
|
281
309
|
BanditAlgorithm2["CONTEXTUAL_LINEAR"] = "contextual_linear";
|
|
282
310
|
})(BanditAlgorithm || (BanditAlgorithm = {}));
|
|
311
|
+
var Posterior;
|
|
312
|
+
(function(Posterior2) {
|
|
313
|
+
Posterior2["BETA"] = "beta";
|
|
314
|
+
Posterior2["NORMAL_GAMMA"] = "normal_gamma";
|
|
315
|
+
})(Posterior || (Posterior = {}));
|
|
316
|
+
function normalizePosterior(posterior) {
|
|
317
|
+
return posterior === Posterior.NORMAL_GAMMA ? Posterior.NORMAL_GAMMA : Posterior.BETA;
|
|
318
|
+
}
|
|
283
319
|
var ForgettingMode;
|
|
284
320
|
(function(ForgettingMode2) {
|
|
285
321
|
ForgettingMode2["NONE"] = "none";
|
|
@@ -450,6 +486,251 @@ function buildSubdomainUrl(subdomain, baseDomain, path = "", protocol = "https")
|
|
|
450
486
|
return path ? `${url}${path.startsWith("/") ? path : "/" + path}` : url;
|
|
451
487
|
}
|
|
452
488
|
|
|
489
|
+
// ../shared/dist/email-token.js
|
|
490
|
+
var EMAIL_TOKEN_PARAM = "bt";
|
|
491
|
+
var encoder = new TextEncoder();
|
|
492
|
+
function base64UrlEncode(bytes) {
|
|
493
|
+
let binary = "";
|
|
494
|
+
for (const byte of bytes)
|
|
495
|
+
binary += String.fromCharCode(byte);
|
|
496
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
497
|
+
}
|
|
498
|
+
function base64UrlDecode(value) {
|
|
499
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
500
|
+
const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
501
|
+
const bytes = new Uint8Array(binary.length);
|
|
502
|
+
for (let i = 0; i < binary.length; i++)
|
|
503
|
+
bytes[i] = binary.charCodeAt(i);
|
|
504
|
+
return bytes;
|
|
505
|
+
}
|
|
506
|
+
async function hmac(secret, message) {
|
|
507
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
508
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
|
|
509
|
+
}
|
|
510
|
+
function timingSafeEqual(a, b) {
|
|
511
|
+
if (a.length !== b.length)
|
|
512
|
+
return false;
|
|
513
|
+
let diff = 0;
|
|
514
|
+
for (let i = 0; i < a.length; i++)
|
|
515
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
516
|
+
return diff === 0;
|
|
517
|
+
}
|
|
518
|
+
async function hashRecipient(recipientId, companySecret) {
|
|
519
|
+
const digest = await hmac(companySecret, `recipient:${recipientId}`);
|
|
520
|
+
return base64UrlEncode(digest.slice(0, 16));
|
|
521
|
+
}
|
|
522
|
+
async function mintEmailToken(payload, key) {
|
|
523
|
+
const body = base64UrlEncode(encoder.encode(JSON.stringify({
|
|
524
|
+
c: payload.c,
|
|
525
|
+
r: payload.r,
|
|
526
|
+
k: payload.k,
|
|
527
|
+
l: payload.l,
|
|
528
|
+
v: key.version
|
|
529
|
+
})));
|
|
530
|
+
const signature = base64UrlEncode(await hmac(key.secret, body));
|
|
531
|
+
return `${body}.${signature}`;
|
|
532
|
+
}
|
|
533
|
+
async function verifyEmailToken(token, keys) {
|
|
534
|
+
const parts = token.split(".");
|
|
535
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
536
|
+
return { valid: false, reason: "malformed" };
|
|
537
|
+
}
|
|
538
|
+
const [body, signature] = parts;
|
|
539
|
+
let payload;
|
|
540
|
+
try {
|
|
541
|
+
payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
|
|
542
|
+
} catch {
|
|
543
|
+
return { valid: false, reason: "malformed" };
|
|
544
|
+
}
|
|
545
|
+
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)) {
|
|
546
|
+
return { valid: false, reason: "malformed" };
|
|
547
|
+
}
|
|
548
|
+
const key = keys.find((candidate) => candidate.version === payload.v);
|
|
549
|
+
if (!key)
|
|
550
|
+
return { valid: false, reason: "unknown_key" };
|
|
551
|
+
const expected = base64UrlEncode(await hmac(key.secret, body));
|
|
552
|
+
if (!timingSafeEqual(expected, signature)) {
|
|
553
|
+
return { valid: false, reason: "bad_signature" };
|
|
554
|
+
}
|
|
555
|
+
return { valid: true, payload };
|
|
556
|
+
}
|
|
557
|
+
function appendEmailToken(url, token) {
|
|
558
|
+
const hashIndex = url.indexOf("#");
|
|
559
|
+
const fragment = hashIndex === -1 ? "" : url.slice(hashIndex);
|
|
560
|
+
const base = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
561
|
+
const separator = base.includes("?") ? "&" : "?";
|
|
562
|
+
return `${base}${separator}${EMAIL_TOKEN_PARAM}=${encodeURIComponent(token)}${fragment}`;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ../shared/dist/email-plan.js
|
|
566
|
+
async function uniformFor(salt, recipientId, slotIndex) {
|
|
567
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(salt), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
568
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${recipientId}:${slotIndex}`)));
|
|
569
|
+
let value = 0;
|
|
570
|
+
for (let i = 0; i < 6; i++)
|
|
571
|
+
value = value * 256 + mac[i];
|
|
572
|
+
return value / 2 ** 48;
|
|
573
|
+
}
|
|
574
|
+
function pickArm(probabilities, uniform) {
|
|
575
|
+
if (probabilities.length === 0)
|
|
576
|
+
return -1;
|
|
577
|
+
const total = probabilities.reduce((sum, p) => sum + Math.max(p, 0), 0);
|
|
578
|
+
if (total <= 0) {
|
|
579
|
+
return Math.min(probabilities.length - 1, Math.floor(uniform * probabilities.length));
|
|
580
|
+
}
|
|
581
|
+
const target = uniform * total;
|
|
582
|
+
let cumulative = 0;
|
|
583
|
+
for (let i = 0; i < probabilities.length; i++) {
|
|
584
|
+
cumulative += Math.max(probabilities[i], 0);
|
|
585
|
+
if (target < cumulative)
|
|
586
|
+
return i;
|
|
587
|
+
}
|
|
588
|
+
return probabilities.length - 1;
|
|
589
|
+
}
|
|
590
|
+
async function sampleCombination(plan, recipientId) {
|
|
591
|
+
const combination = [];
|
|
592
|
+
for (let i = 0; i < plan.slots.length; i++) {
|
|
593
|
+
const slot = plan.slots[i];
|
|
594
|
+
const uniform = await uniformFor(plan.salt, recipientId, i);
|
|
595
|
+
const chosen = pickArm(slot.probabilities, uniform);
|
|
596
|
+
combination.push(chosen === -1 ? 0 : slot.arms[chosen]?.armIndex ?? 0);
|
|
597
|
+
}
|
|
598
|
+
return combination;
|
|
599
|
+
}
|
|
600
|
+
async function impressionManifest(plan, recipientIds) {
|
|
601
|
+
const counts = /* @__PURE__ */ new Map();
|
|
602
|
+
for (const recipientId of recipientIds) {
|
|
603
|
+
for (let i = 0; i < plan.slots.length; i++) {
|
|
604
|
+
const slot = plan.slots[i];
|
|
605
|
+
const uniform = await uniformFor(plan.salt, recipientId, i);
|
|
606
|
+
const chosen = pickArm(slot.probabilities, uniform);
|
|
607
|
+
const arm = slot.arms[chosen === -1 ? 0 : chosen];
|
|
608
|
+
if (!arm)
|
|
609
|
+
continue;
|
|
610
|
+
const key = `${slot.experimentId}:${arm.treatmentId}`;
|
|
611
|
+
const entry = counts.get(key);
|
|
612
|
+
if (entry)
|
|
613
|
+
entry.count++;
|
|
614
|
+
else
|
|
615
|
+
counts.set(key, {
|
|
616
|
+
experimentId: slot.experimentId,
|
|
617
|
+
treatmentId: arm.treatmentId,
|
|
618
|
+
count: 1
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return Array.from(counts.values());
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ../shared/dist/email-blocks.js
|
|
626
|
+
function isSlotBinding(value) {
|
|
627
|
+
return typeof value === "object" && value !== null && typeof value.$slot === "string";
|
|
628
|
+
}
|
|
629
|
+
var CONTENT_WIDTH = 600;
|
|
630
|
+
function escapeHtml(value) {
|
|
631
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
632
|
+
}
|
|
633
|
+
function resolve(value, slots, missing) {
|
|
634
|
+
if (value === void 0)
|
|
635
|
+
return "";
|
|
636
|
+
if (!isSlotBinding(value))
|
|
637
|
+
return value;
|
|
638
|
+
const resolved = slots[value.$slot];
|
|
639
|
+
if (resolved === void 0) {
|
|
640
|
+
missing.push(value.$slot);
|
|
641
|
+
return "";
|
|
642
|
+
}
|
|
643
|
+
return resolved;
|
|
644
|
+
}
|
|
645
|
+
function extractDocumentSlots(document) {
|
|
646
|
+
const names = [];
|
|
647
|
+
const seen = /* @__PURE__ */ new Set();
|
|
648
|
+
const visit = (value) => {
|
|
649
|
+
if (isSlotBinding(value)) {
|
|
650
|
+
if (!seen.has(value.$slot)) {
|
|
651
|
+
seen.add(value.$slot);
|
|
652
|
+
names.push(value.$slot);
|
|
653
|
+
}
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (Array.isArray(value))
|
|
657
|
+
value.forEach(visit);
|
|
658
|
+
else if (typeof value === "object" && value !== null)
|
|
659
|
+
Object.values(value).forEach(visit);
|
|
660
|
+
};
|
|
661
|
+
visit(document.subject);
|
|
662
|
+
visit(document.preheader);
|
|
663
|
+
document.blocks.forEach(visit);
|
|
664
|
+
return names;
|
|
665
|
+
}
|
|
666
|
+
function renderBlock(block, slots, missing) {
|
|
667
|
+
const align = block.align ?? "left";
|
|
668
|
+
switch (block.type) {
|
|
669
|
+
case "heading": {
|
|
670
|
+
const size = block.level === 3 ? 18 : block.level === 2 ? 22 : 28;
|
|
671
|
+
const color = escapeHtml(resolve(block.color, slots, missing) || "#111111");
|
|
672
|
+
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>`;
|
|
673
|
+
}
|
|
674
|
+
case "text": {
|
|
675
|
+
const color = escapeHtml(resolve(block.color, slots, missing) || "#333333");
|
|
676
|
+
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>`;
|
|
677
|
+
}
|
|
678
|
+
case "button": {
|
|
679
|
+
const href = escapeHtml(resolve(block.href, slots, missing));
|
|
680
|
+
const label = escapeHtml(resolve(block.text, slots, missing));
|
|
681
|
+
const bg = escapeHtml(resolve(block.backgroundColor, slots, missing) || "#1a73e8");
|
|
682
|
+
const fg = escapeHtml(resolve(block.textColor, slots, missing) || "#ffffff");
|
|
683
|
+
return `<tr><td style="padding:16px 24px;text-align:${align}">
|
|
684
|
+
<!--[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]-->
|
|
685
|
+
<!--[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]-->
|
|
686
|
+
</td></tr>`;
|
|
687
|
+
}
|
|
688
|
+
case "image": {
|
|
689
|
+
const src = escapeHtml(resolve(block.src, slots, missing));
|
|
690
|
+
const alt = escapeHtml(resolve(block.alt, slots, missing));
|
|
691
|
+
const width = block.width ?? CONTENT_WIDTH;
|
|
692
|
+
const img = `<img src="${src}" alt="${alt}" width="${width}" style="display:block;border:0;outline:none;text-decoration:none;max-width:100%;height:auto" />`;
|
|
693
|
+
const href = block.href ? escapeHtml(resolve(block.href, slots, missing)) : "";
|
|
694
|
+
return `<tr><td style="padding:0;text-align:center">${href ? `<a href="${href}">${img}</a>` : img}</td></tr>`;
|
|
695
|
+
}
|
|
696
|
+
case "divider":
|
|
697
|
+
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>`;
|
|
698
|
+
case "spacer":
|
|
699
|
+
return `<tr><td style="height:${block.height ?? 24}px;font-size:0;line-height:0"> </td></tr>`;
|
|
700
|
+
default:
|
|
701
|
+
return "";
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function renderEmailDocument(document, slots) {
|
|
705
|
+
const missingSlots = [];
|
|
706
|
+
const width = document.contentWidth ?? CONTENT_WIDTH;
|
|
707
|
+
const background = escapeHtml(document.backgroundColor ?? "#f4f4f5");
|
|
708
|
+
const subject = resolve(document.subject, slots, missingSlots);
|
|
709
|
+
const preheader = resolve(document.preheader, slots, missingSlots);
|
|
710
|
+
const body = document.blocks.map((block) => renderBlock(block, slots, missingSlots)).join("\n");
|
|
711
|
+
const html = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
712
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
713
|
+
<head>
|
|
714
|
+
<meta charset="utf-8" />
|
|
715
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
716
|
+
<meta name="x-apple-disable-message-reformatting" />
|
|
717
|
+
<title>${escapeHtml(subject)}</title>
|
|
718
|
+
<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
|
|
719
|
+
</head>
|
|
720
|
+
<body style="margin:0;padding:0;background-color:${background}">
|
|
721
|
+
<div style="display:none;font-size:0;line-height:0;max-height:0;overflow:hidden;mso-hide:all">${escapeHtml(preheader)}</div>
|
|
722
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:${background}">
|
|
723
|
+
<tr><td align="center" style="padding:24px 12px">
|
|
724
|
+
<table role="presentation" width="${width}" cellpadding="0" cellspacing="0" border="0" style="width:${width}px;max-width:100%;background-color:#ffffff;border-radius:8px">
|
|
725
|
+
${body}
|
|
726
|
+
</table>
|
|
727
|
+
</td></tr>
|
|
728
|
+
</table>
|
|
729
|
+
</body>
|
|
730
|
+
</html>`;
|
|
731
|
+
return { subject, html, missingSlots };
|
|
732
|
+
}
|
|
733
|
+
|
|
453
734
|
// src/snapshot-cache.ts
|
|
454
735
|
var STORAGE_KEY = "bandits_snapshot";
|
|
455
736
|
var REPORT_QUEUE_KEY = "bandits_pending_reports";
|
|
@@ -672,6 +953,201 @@ var SnapshotClient = class {
|
|
|
672
953
|
}
|
|
673
954
|
}
|
|
674
955
|
};
|
|
956
|
+
|
|
957
|
+
// src/email-links.ts
|
|
958
|
+
var UNTRACKABLE_SCHEME = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
|
|
959
|
+
var NOTRACK_ATTRIBUTE = "data-bandit-notrack";
|
|
960
|
+
function isTrackable(url) {
|
|
961
|
+
const trimmed = url.trim();
|
|
962
|
+
if (!trimmed || UNTRACKABLE_SCHEME.test(trimmed)) return false;
|
|
963
|
+
return /^https?:\/\//i.test(trimmed);
|
|
964
|
+
}
|
|
965
|
+
function appendToken(url, token) {
|
|
966
|
+
const hashIndex = url.indexOf("#");
|
|
967
|
+
const fragment = hashIndex === -1 ? "" : url.slice(hashIndex);
|
|
968
|
+
const base = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
969
|
+
const separator = base.includes("?") ? base.includes("&") ? "&" : "&" : "?";
|
|
970
|
+
return `${base}${separator}${EMAIL_TOKEN_PARAM}=${token}${fragment}`;
|
|
971
|
+
}
|
|
972
|
+
function scanAnchors(html) {
|
|
973
|
+
const anchors = [];
|
|
974
|
+
let i = 0;
|
|
975
|
+
while (i < html.length) {
|
|
976
|
+
const lt = html.indexOf("<", i);
|
|
977
|
+
if (lt === -1) break;
|
|
978
|
+
if (html.startsWith("<!--", lt)) {
|
|
979
|
+
if (html.startsWith("<!--[if", lt) || html.startsWith("<!--<![endif]", lt)) {
|
|
980
|
+
const close = html.indexOf(">", lt);
|
|
981
|
+
i = close === -1 ? html.length : close + 1;
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
const end = html.indexOf("-->", lt);
|
|
985
|
+
i = end === -1 ? html.length : end + 3;
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
const rawText = /^<(script|style)\b/i.exec(html.slice(lt, lt + 8));
|
|
989
|
+
if (rawText) {
|
|
990
|
+
const closing = new RegExp(`</${rawText[1]}\\s*>`, "i");
|
|
991
|
+
const rest = html.slice(lt);
|
|
992
|
+
const match = closing.exec(rest);
|
|
993
|
+
i = match ? lt + match.index + match[0].length : html.length;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
const isLinkElement = /^<a[\s/>]/i.test(html.slice(lt, lt + 3)) || /^<v:roundrect[\s/>]/i.test(html.slice(lt, lt + 13));
|
|
997
|
+
if (!isLinkElement) {
|
|
998
|
+
i = lt + 1;
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const tagEnd = findTagEnd(html, lt);
|
|
1002
|
+
if (tagEnd === -1) break;
|
|
1003
|
+
const tag = html.slice(lt, tagEnd);
|
|
1004
|
+
if (!new RegExp(NOTRACK_ATTRIBUTE, "i").test(tag)) {
|
|
1005
|
+
const href = findHref(html, lt, tagEnd);
|
|
1006
|
+
if (href && isTrackable(href.url)) anchors.push(href);
|
|
1007
|
+
}
|
|
1008
|
+
i = tagEnd;
|
|
1009
|
+
}
|
|
1010
|
+
return anchors;
|
|
1011
|
+
}
|
|
1012
|
+
function findTagEnd(html, start) {
|
|
1013
|
+
let quote = null;
|
|
1014
|
+
for (let i = start + 1; i < html.length; i++) {
|
|
1015
|
+
const ch = html[i];
|
|
1016
|
+
if (quote) {
|
|
1017
|
+
if (ch === quote) quote = null;
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
if (ch === '"' || ch === "'") {
|
|
1021
|
+
quote = ch;
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (ch === ">") return i + 1;
|
|
1025
|
+
}
|
|
1026
|
+
return -1;
|
|
1027
|
+
}
|
|
1028
|
+
function findHref(html, tagStart, tagEnd) {
|
|
1029
|
+
const tag = html.slice(tagStart, tagEnd);
|
|
1030
|
+
const match = /\shref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag);
|
|
1031
|
+
if (!match) return null;
|
|
1032
|
+
const quoted = match[2] !== void 0 || match[3] !== void 0;
|
|
1033
|
+
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
1034
|
+
const valueStart = tagStart + match.index + match[0].indexOf(match[1]) + (quoted ? 1 : 0);
|
|
1035
|
+
return { valueStart, valueEnd: valueStart + value.length, url: value };
|
|
1036
|
+
}
|
|
1037
|
+
function extractEmailLinks(html) {
|
|
1038
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1039
|
+
const urls = [];
|
|
1040
|
+
for (const anchor of scanAnchors(html)) {
|
|
1041
|
+
if (seen.has(anchor.url)) continue;
|
|
1042
|
+
seen.add(anchor.url);
|
|
1043
|
+
urls.push(anchor.url);
|
|
1044
|
+
}
|
|
1045
|
+
return urls;
|
|
1046
|
+
}
|
|
1047
|
+
function rewriteEmailLinks(html, options) {
|
|
1048
|
+
const { linkTable, tokens } = options;
|
|
1049
|
+
const indexOfUrl = /* @__PURE__ */ new Map();
|
|
1050
|
+
linkTable.forEach((url, index) => {
|
|
1051
|
+
if (!indexOfUrl.has(url)) indexOfUrl.set(url, index);
|
|
1052
|
+
});
|
|
1053
|
+
const anchors = scanAnchors(html);
|
|
1054
|
+
const untracked = [];
|
|
1055
|
+
let rewritten = 0;
|
|
1056
|
+
let output = html;
|
|
1057
|
+
for (let i = anchors.length - 1; i >= 0; i--) {
|
|
1058
|
+
const anchor = anchors[i];
|
|
1059
|
+
const index = indexOfUrl.get(anchor.url);
|
|
1060
|
+
if (index === void 0 || tokens[index] === void 0) {
|
|
1061
|
+
untracked.push(anchor.url);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
output = output.slice(0, anchor.valueStart) + appendToken(anchor.url, tokens[index]) + output.slice(anchor.valueEnd);
|
|
1065
|
+
rewritten++;
|
|
1066
|
+
}
|
|
1067
|
+
return { html: output, rewritten, untracked: untracked.reverse() };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// src/email.ts
|
|
1071
|
+
var SLOT_PATTERN = /\{\{\s*bandit\.([a-zA-Z0-9_-]+)\s*\}\}/g;
|
|
1072
|
+
function renderEmail(templateHtml, options) {
|
|
1073
|
+
const missingSlots = [];
|
|
1074
|
+
const substituted = templateHtml.replace(SLOT_PATTERN, (match, name) => {
|
|
1075
|
+
const value = options.slots[name];
|
|
1076
|
+
if (value === void 0) {
|
|
1077
|
+
missingSlots.push(name);
|
|
1078
|
+
return match;
|
|
1079
|
+
}
|
|
1080
|
+
return value;
|
|
1081
|
+
});
|
|
1082
|
+
const { html, rewritten, untracked } = rewriteEmailLinks(substituted, {
|
|
1083
|
+
linkTable: options.linkTable,
|
|
1084
|
+
tokens: options.tokens
|
|
1085
|
+
});
|
|
1086
|
+
return { html, rewritten, untracked, missingSlots };
|
|
1087
|
+
}
|
|
1088
|
+
function extractSlotNames(templateHtml) {
|
|
1089
|
+
const names = /* @__PURE__ */ new Set();
|
|
1090
|
+
for (const match of templateHtml.matchAll(SLOT_PATTERN)) names.add(match[1]);
|
|
1091
|
+
return [...names];
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// src/email-plan.ts
|
|
1095
|
+
async function renderFromPlan(plan, recipientId, templateHtml, companySecret) {
|
|
1096
|
+
const slots = {};
|
|
1097
|
+
const combination = [];
|
|
1098
|
+
for (let i = 0; i < plan.slots.length; i++) {
|
|
1099
|
+
const slot = plan.slots[i];
|
|
1100
|
+
const chosen = pickArm(slot.probabilities, await uniformFor(plan.salt, recipientId, i));
|
|
1101
|
+
const index = chosen === -1 ? 0 : chosen;
|
|
1102
|
+
combination.push(slot.arms[index]?.armIndex ?? 0);
|
|
1103
|
+
const config = slot.configs[index];
|
|
1104
|
+
slots[slot.name] = typeof config === "string" ? config : config?.content ?? config?.text ?? "";
|
|
1105
|
+
}
|
|
1106
|
+
const recipientHash = await hashRecipient(recipientId, companySecret);
|
|
1107
|
+
const tokens = await Promise.all(
|
|
1108
|
+
plan.linkTable.map(
|
|
1109
|
+
(_, linkIndex) => mintEmailToken(
|
|
1110
|
+
{ c: plan.campaignId, r: recipientHash, k: combination, l: linkIndex },
|
|
1111
|
+
plan.signingKey
|
|
1112
|
+
)
|
|
1113
|
+
)
|
|
1114
|
+
);
|
|
1115
|
+
const rendered = renderEmail(templateHtml, {
|
|
1116
|
+
slots,
|
|
1117
|
+
linkTable: plan.linkTable,
|
|
1118
|
+
tokens
|
|
1119
|
+
});
|
|
1120
|
+
return { ...rendered, combination, tokens };
|
|
1121
|
+
}
|
|
1122
|
+
function shouldRefreshPlan(plan, recipientsRenderedSincePlan, now = Date.now()) {
|
|
1123
|
+
return recipientsRenderedSincePlan >= plan.refreshEvery || now >= plan.expiresAt;
|
|
1124
|
+
}
|
|
1125
|
+
async function reportPlanImpressions(plan, recipientIds, options) {
|
|
1126
|
+
const counts = await impressionManifest(plan, recipientIds);
|
|
1127
|
+
if (counts.length === 0) return { recorded: false, alreadyRecorded: false };
|
|
1128
|
+
const response = await fetch(`${options.apiUrl}/api/email/impressions`, {
|
|
1129
|
+
method: "POST",
|
|
1130
|
+
headers: { "Content-Type": "application/json", "x-api-key": options.apiKey },
|
|
1131
|
+
body: JSON.stringify({
|
|
1132
|
+
campaignId: plan.campaignId,
|
|
1133
|
+
// Derived from the recipients this call covers, so a retry of the same
|
|
1134
|
+
// chunk collapses onto the same key without the caller needing to
|
|
1135
|
+
// invent and persist an idempotency token of its own.
|
|
1136
|
+
batchKey: options.batchKey ?? await batchKeyFor(plan.campaignId, recipientIds),
|
|
1137
|
+
counts
|
|
1138
|
+
})
|
|
1139
|
+
});
|
|
1140
|
+
if (!response.ok) {
|
|
1141
|
+
throw new Error(`Failed to report impressions: ${response.status}`);
|
|
1142
|
+
}
|
|
1143
|
+
const body = await response.json();
|
|
1144
|
+
return body.data ?? { recorded: false, alreadyRecorded: false };
|
|
1145
|
+
}
|
|
1146
|
+
async function batchKeyFor(campaignId, recipientIds) {
|
|
1147
|
+
const material = `${campaignId}|${[...recipientIds].sort().join(",")}`;
|
|
1148
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
|
|
1149
|
+
return Array.from(new Uint8Array(digest).slice(0, 16)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1150
|
+
}
|
|
675
1151
|
export {
|
|
676
1152
|
BanditAlgorithm,
|
|
677
1153
|
BanditsClient as BanditClient,
|
|
@@ -680,27 +1156,49 @@ export {
|
|
|
680
1156
|
DEFAULT_HALF_LIFE_MS,
|
|
681
1157
|
DEFAULT_SESSION_TTL_MS,
|
|
682
1158
|
DEFAULT_WINDOW_MS,
|
|
1159
|
+
EMAIL_TOKEN_PARAM,
|
|
683
1160
|
ExperimentStatus,
|
|
684
1161
|
ForgettingMode,
|
|
685
1162
|
FunnelStatus,
|
|
1163
|
+
PLACEMENTS,
|
|
1164
|
+
Posterior,
|
|
686
1165
|
SESSION_CREATED_AT_KEY,
|
|
687
1166
|
SESSION_ID_KEY,
|
|
688
1167
|
STATIONARY,
|
|
689
1168
|
SnapshotClient,
|
|
1169
|
+
appendEmailToken,
|
|
690
1170
|
buildSubdomainUrl,
|
|
691
1171
|
calculateConfidenceInterval,
|
|
1172
|
+
extractDocumentSlots,
|
|
1173
|
+
extractEmailLinks,
|
|
1174
|
+
extractSlotNames,
|
|
692
1175
|
extractSubdomain,
|
|
693
1176
|
generateId2 as generateId,
|
|
694
1177
|
generateSubdomain,
|
|
695
1178
|
getContentTypeCategory,
|
|
696
1179
|
getOrCreateSessionId,
|
|
1180
|
+
hashRecipient,
|
|
1181
|
+
impressionManifest,
|
|
1182
|
+
isSlotBinding,
|
|
697
1183
|
isValidEmail,
|
|
698
1184
|
isValidSubdomain,
|
|
1185
|
+
mintEmailToken,
|
|
699
1186
|
normalizeAlgorithm,
|
|
1187
|
+
normalizePosterior,
|
|
700
1188
|
normalizeTreatmentWeights,
|
|
1189
|
+
pickArm,
|
|
701
1190
|
pickTreatment,
|
|
1191
|
+
renderEmail,
|
|
1192
|
+
renderEmailDocument,
|
|
1193
|
+
renderFromPlan,
|
|
1194
|
+
reportPlanImpressions,
|
|
702
1195
|
resolveForgetting,
|
|
1196
|
+
rewriteEmailLinks,
|
|
1197
|
+
sampleCombination,
|
|
703
1198
|
sanitizeMetadata,
|
|
704
|
-
|
|
1199
|
+
shouldRefreshPlan,
|
|
1200
|
+
uniformFor,
|
|
1201
|
+
validateTreatmentWeights,
|
|
1202
|
+
verifyEmailToken
|
|
705
1203
|
};
|
|
706
1204
|
//# sourceMappingURL=index.js.map
|