@runbandit/sdk 1.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/dist/index.js CHANGED
@@ -1,4 +1,45 @@
1
+ // src/session.ts
2
+ var SESSION_ID_KEY = "bandits_session_id";
3
+ var SESSION_CREATED_AT_KEY = "bandits_session_created_at";
4
+ var DEFAULT_SESSION_TTL_MS = 864e5;
5
+ function generateId() {
6
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
7
+ return crypto.randomUUID();
8
+ }
9
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
10
+ const r = Math.random() * 16 | 0;
11
+ return (c === "x" ? r : r & 3 | 8).toString(16);
12
+ });
13
+ }
14
+ function getOrCreateSessionId(options = {}) {
15
+ const { storage, now = () => Date.now(), ttlMs = DEFAULT_SESSION_TTL_MS } = options;
16
+ if (!storage) return generateId();
17
+ try {
18
+ const stored = storage.getItem(SESSION_ID_KEY);
19
+ const storedAt = storage.getItem(SESSION_CREATED_AT_KEY);
20
+ if (stored && storedAt) {
21
+ const age = now() - parseInt(storedAt, 10);
22
+ if (age < ttlMs) return stored;
23
+ }
24
+ const id = generateId();
25
+ storage.setItem(SESSION_ID_KEY, id);
26
+ storage.setItem(SESSION_CREATED_AT_KEY, String(now()));
27
+ return id;
28
+ } catch {
29
+ return generateId();
30
+ }
31
+ }
32
+ function defaultSessionStorage() {
33
+ return typeof window !== "undefined" && window.localStorage ? window.localStorage : null;
34
+ }
35
+
1
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
+ }
2
43
  var BanditsClient = class {
3
44
  config;
4
45
  namedEventQueue = [];
@@ -23,6 +64,8 @@ var BanditsClient = class {
23
64
  context,
24
65
  sessionId: this.sessionId
25
66
  };
67
+ const pageUrl = currentPageUrl();
68
+ if (pageUrl) request.pageUrl = pageUrl;
26
69
  const response = await this.makeRequest(
27
70
  "/assignments",
28
71
  "POST",
@@ -57,7 +100,7 @@ var BanditsClient = class {
57
100
  sessionId: this.sessionId
58
101
  };
59
102
  if (opts?.immediate) {
60
- const url = `${this.config.apiUrl}/events`;
103
+ const url = `${this.normalizeApiUrl()}/events`;
61
104
  try {
62
105
  void fetch(url, {
63
106
  method: "POST",
@@ -82,7 +125,7 @@ var BanditsClient = class {
82
125
  this.namedEventQueue = [];
83
126
  if (namedEvents.length === 0) return;
84
127
  try {
85
- await fetch(`${this.config.apiUrl}/events/batch`, {
128
+ await fetch(`${this.normalizeApiUrl()}/events/batch`, {
86
129
  method: "POST",
87
130
  headers: {
88
131
  "Authorization": `Bearer ${this.config.apiKey}`,
@@ -144,34 +187,59 @@ var BanditsClient = class {
144
187
  throw new Error(response.error || "Failed to record funnel reward");
145
188
  }
146
189
  }
147
- generateId() {
148
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
149
- return crypto.randomUUID();
190
+ async getBundle(bundleId, userId, context) {
191
+ const request = {
192
+ bundleId,
193
+ userId,
194
+ context,
195
+ sessionId: this.sessionId
196
+ };
197
+ const pageUrl = currentPageUrl();
198
+ if (pageUrl) request.pageUrl = pageUrl;
199
+ const response = await this.makeRequest("/bundles/assignment", "POST", request);
200
+ if (!response.success || !response.data) {
201
+ throw new Error(response.error || "Failed to get bundle assignment");
202
+ }
203
+ const payload = response.data?.data ?? response.data;
204
+ if (!payload?.bundleAssignmentId || !payload?.slots) {
205
+ throw new Error("Invalid bundle assignment response");
150
206
  }
151
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
152
- const r = Math.random() * 16 | 0;
153
- return (c === "x" ? r : r & 3 | 8).toString(16);
207
+ return {
208
+ bundleAssignmentId: payload.bundleAssignmentId,
209
+ slots: payload.slots
210
+ };
211
+ }
212
+ async trackBundleReward(bundleId, userId, value) {
213
+ const response = await this.makeRequest(`/bundles/${bundleId}/reward`, "POST", {
214
+ userId,
215
+ value
154
216
  });
217
+ if (!response.success) {
218
+ throw new Error(response.error || "Failed to record bundle reward");
219
+ }
155
220
  }
156
- getOrCreateSessionId() {
157
- if (typeof window !== "undefined" && window.localStorage) {
158
- const stored = localStorage.getItem("bandits_session_id");
159
- const storedAt = localStorage.getItem("bandits_session_created_at");
160
- if (stored && storedAt) {
161
- const age = Date.now() - parseInt(storedAt, 10);
162
- if (age < this.config.sessionTtl) {
163
- return stored;
164
- }
165
- }
166
- const id = this.generateId();
167
- localStorage.setItem("bandits_session_id", id);
168
- localStorage.setItem("bandits_session_created_at", String(Date.now()));
169
- return id;
221
+ generateId() {
222
+ return generateId();
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`;
170
232
  }
171
- return this.generateId();
233
+ return baseUrl;
234
+ }
235
+ getOrCreateSessionId() {
236
+ return getOrCreateSessionId({
237
+ storage: defaultSessionStorage(),
238
+ ttlMs: this.config.sessionTtl
239
+ });
172
240
  }
173
241
  async makeRequest(endpoint, method, body, extraHeaders) {
174
- const url = `${this.config.apiUrl}${endpoint}`;
242
+ const url = `${this.normalizeApiUrl()}${endpoint}`;
175
243
  const headers = {
176
244
  "Authorization": `Bearer ${this.config.apiKey}`,
177
245
  "Content-Type": "application/json",
@@ -220,6 +288,13 @@ var BanditsClient = class {
220
288
  };
221
289
 
222
290
  // ../shared/dist/types.js
291
+ var PLACEMENTS = [
292
+ "hero_headline",
293
+ "hero_subhead",
294
+ "cta_primary",
295
+ "banner_text",
296
+ "badge_text"
297
+ ];
223
298
  var ExperimentStatus;
224
299
  (function(ExperimentStatus2) {
225
300
  ExperimentStatus2["DRAFT"] = "draft";
@@ -230,12 +305,54 @@ var ExperimentStatus;
230
305
  })(ExperimentStatus || (ExperimentStatus = {}));
231
306
  var BanditAlgorithm;
232
307
  (function(BanditAlgorithm2) {
233
- BanditAlgorithm2["EPSILON_GREEDY"] = "epsilon_greedy";
234
- BanditAlgorithm2["UCB1"] = "ucb1";
235
308
  BanditAlgorithm2["THOMPSON_SAMPLING"] = "thompson_sampling";
236
309
  BanditAlgorithm2["CONTEXTUAL_LINEAR"] = "contextual_linear";
237
- BanditAlgorithm2["CONTEXTUAL_NEURAL"] = "contextual_neural";
238
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
+ }
319
+ var ForgettingMode;
320
+ (function(ForgettingMode2) {
321
+ ForgettingMode2["NONE"] = "none";
322
+ ForgettingMode2["DISCOUNTED"] = "discounted";
323
+ ForgettingMode2["SLIDING_WINDOW"] = "sliding_window";
324
+ })(ForgettingMode || (ForgettingMode = {}));
325
+ var DEFAULT_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1e3;
326
+ var DEFAULT_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
327
+ var STATIONARY = { mode: ForgettingMode.NONE };
328
+ function resolveForgetting(config) {
329
+ switch (config?.mode) {
330
+ case ForgettingMode.DISCOUNTED:
331
+ return {
332
+ mode: ForgettingMode.DISCOUNTED,
333
+ halfLifeMs: config.halfLifeMs && config.halfLifeMs > 0 ? config.halfLifeMs : DEFAULT_HALF_LIFE_MS
334
+ };
335
+ case ForgettingMode.SLIDING_WINDOW:
336
+ return {
337
+ mode: ForgettingMode.SLIDING_WINDOW,
338
+ windowMs: config.windowMs && config.windowMs > 0 ? config.windowMs : DEFAULT_WINDOW_MS
339
+ };
340
+ default:
341
+ return STATIONARY;
342
+ }
343
+ }
344
+ var LEGACY_ALGORITHM_ALIASES = {
345
+ epsilon_greedy: BanditAlgorithm.THOMPSON_SAMPLING,
346
+ ucb1: BanditAlgorithm.THOMPSON_SAMPLING,
347
+ contextual_neural: BanditAlgorithm.CONTEXTUAL_LINEAR
348
+ };
349
+ function normalizeAlgorithm(algorithm) {
350
+ if (algorithm === BanditAlgorithm.THOMPSON_SAMPLING)
351
+ return BanditAlgorithm.THOMPSON_SAMPLING;
352
+ if (algorithm === BanditAlgorithm.CONTEXTUAL_LINEAR)
353
+ return BanditAlgorithm.CONTEXTUAL_LINEAR;
354
+ return LEGACY_ALGORITHM_ALIASES[algorithm ?? ""] ?? BanditAlgorithm.THOMPSON_SAMPLING;
355
+ }
239
356
  var ContentType;
240
357
  (function(ContentType2) {
241
358
  ContentType2["PLAIN_TEXT"] = "plain_text";
@@ -275,7 +392,7 @@ var FunnelStatus;
275
392
  })(FunnelStatus || (FunnelStatus = {}));
276
393
 
277
394
  // ../shared/dist/utils.js
278
- function generateId() {
395
+ function generateId2() {
279
396
  return Math.random().toString(36).substring(2) + Date.now().toString(36);
280
397
  }
281
398
  function validateTreatmentWeights(treatments) {
@@ -368,23 +485,720 @@ function buildSubdomainUrl(subdomain, baseDomain, path = "", protocol = "https")
368
485
  const url = `${protocol}://${subdomain}.${baseDomain}`;
369
486
  return path ? `${url}${path.startsWith("/") ? path : "/" + path}` : url;
370
487
  }
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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">&nbsp;</td></tr></table></td></tr>`;
698
+ case "spacer":
699
+ return `<tr><td style="height:${block.height ?? 24}px;font-size:0;line-height:0">&nbsp;</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
+
734
+ // src/snapshot-cache.ts
735
+ var STORAGE_KEY = "bandits_snapshot";
736
+ var REPORT_QUEUE_KEY = "bandits_pending_reports";
737
+ var DECISIONS_KEY = "bandits_client_assignments";
738
+ function pickTreatment(treatments, draw) {
739
+ if (treatments.length === 0) return null;
740
+ let cumulative = 0;
741
+ for (const treatment of treatments) {
742
+ cumulative += treatment.pct;
743
+ if (draw < cumulative) return treatment;
744
+ }
745
+ return treatments[treatments.length - 1];
746
+ }
747
+ var DEFAULT_ASSIGNMENT_TTL_SECONDS = 86400;
748
+ function randomId() {
749
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
750
+ let id = "";
751
+ for (let i = 0; i < 24; i++) {
752
+ id += chars.charAt(Math.floor(Math.random() * chars.length));
753
+ }
754
+ return id;
755
+ }
756
+ var SnapshotClient = class {
757
+ config;
758
+ storage;
759
+ now;
760
+ fetchImpl;
761
+ snapshot = null;
762
+ queue = [];
763
+ sessionId;
764
+ inFlight = null;
765
+ constructor(config) {
766
+ this.config = config;
767
+ this.now = config.now ?? (() => Date.now());
768
+ this.fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args));
769
+ this.storage = config.storage ?? (typeof window !== "undefined" && window.localStorage ? window.localStorage : null);
770
+ this.queue = this.readQueue();
771
+ this.sessionId = getOrCreateSessionId({
772
+ storage: this.storage ?? defaultSessionStorage(),
773
+ now: this.now,
774
+ ttlMs: config.sessionTtl
775
+ });
776
+ if (config.inlineSnapshot) {
777
+ this.snapshot = config.inlineSnapshot;
778
+ this.persistSnapshot(config.inlineSnapshot);
779
+ } else {
780
+ this.snapshot = this.readSnapshot();
781
+ }
782
+ }
783
+ /**
784
+ * Decide an arm with no network call, or return null.
785
+ *
786
+ * Null means "this caller must fall back" - a cold cache, an expired one, an
787
+ * unknown experiment, or one whose state cannot be served from a static
788
+ * document. Returning null rather than the control is deliberate: the caller
789
+ * renders control itself, and no impression is reported for it. Reporting a
790
+ * forced-control impression would inflate the control arm's trials with
791
+ * decisions the bandit never made.
792
+ */
793
+ getAssignmentSync(experimentId, userId) {
794
+ const snapshot = this.freshSnapshot();
795
+ if (!snapshot) return null;
796
+ const experiment = snapshot.experiments[experimentId];
797
+ if (!experiment || experiment.mode !== "snapshot") return null;
798
+ const key = `${experimentId}:${userId}`;
799
+ const decisions = this.readDecisions();
800
+ const stored = decisions[key];
801
+ if (stored && stored.expiresAt > this.now()) {
802
+ const held = experiment.treatments.find((t) => t.id === stored.treatmentId);
803
+ if (held) {
804
+ return {
805
+ clientAssignmentId: stored.clientAssignmentId,
806
+ experimentId,
807
+ treatmentId: held.id,
808
+ config: held.config
809
+ };
810
+ }
811
+ }
812
+ const treatment = pickTreatment(experiment.treatments, Math.random());
813
+ if (!treatment) return null;
814
+ const clientAssignmentId = randomId();
815
+ const ttlSeconds = experiment.assignmentTtl ?? DEFAULT_ASSIGNMENT_TTL_SECONDS;
816
+ decisions[key] = {
817
+ clientAssignmentId,
818
+ treatmentId: treatment.id,
819
+ // expiresAt is milliseconds here; assignmentTtl arrives in seconds.
820
+ expiresAt: this.now() + ttlSeconds * 1e3
821
+ };
822
+ this.writeDecisions(decisions);
823
+ this.queue.push({
824
+ clientAssignmentId,
825
+ experimentId,
826
+ treatmentId: treatment.id,
827
+ userId,
828
+ sessionId: this.sessionId
829
+ });
830
+ this.persistQueue();
831
+ return {
832
+ clientAssignmentId,
833
+ experimentId,
834
+ treatmentId: treatment.id,
835
+ config: treatment.config
836
+ };
837
+ }
838
+ /** Fetch a fresh snapshot and cache it. Safe to call on every pageview. */
839
+ async refresh() {
840
+ const response = await this.fetchImpl(`${this.config.apiUrl}/snapshot`, {
841
+ method: "GET",
842
+ headers: { "x-api-key": this.config.publishableKey }
843
+ });
844
+ if (!response.ok) return;
845
+ const snapshot = await response.json();
846
+ this.snapshot = snapshot;
847
+ this.persistSnapshot(snapshot);
848
+ }
849
+ /**
850
+ * Post queued impressions.
851
+ *
852
+ * The queue is only cleared on success. A failed flush leaves the reports in
853
+ * storage, so a pageview that happened offline is still counted when the
854
+ * next one succeeds - the trial really did occur, and dropping it would bias
855
+ * the arm's denominator downward.
856
+ */
857
+ async flush() {
858
+ if (this.inFlight) return this.inFlight;
859
+ this.inFlight = this.doFlush().finally(() => {
860
+ this.inFlight = null;
861
+ });
862
+ return this.inFlight;
863
+ }
864
+ async doFlush() {
865
+ if (this.queue.length === 0) return;
866
+ const reports = this.queue.slice(0, 1e3);
867
+ try {
868
+ const response = await this.fetchImpl(`${this.config.apiUrl}/assignments/report`, {
869
+ method: "POST",
870
+ headers: {
871
+ "content-type": "application/json",
872
+ "x-api-key": this.config.publishableKey
873
+ },
874
+ body: JSON.stringify({ reports }),
875
+ keepalive: true
876
+ });
877
+ if (!response.ok) return;
878
+ } catch {
879
+ return;
880
+ }
881
+ this.queue = this.queue.slice(reports.length);
882
+ this.persistQueue();
883
+ }
884
+ /** The session id these reports are attributed under. */
885
+ getSessionId() {
886
+ return this.sessionId;
887
+ }
888
+ pendingReportCount() {
889
+ return this.queue.length;
890
+ }
891
+ /** The cached snapshot, or null when absent or past its ttl. */
892
+ freshSnapshot() {
893
+ if (!this.snapshot) return null;
894
+ if (this.snapshot.ttl === 0) return null;
895
+ const ageMs = this.now() - this.snapshot.generatedAt;
896
+ if (ageMs > this.snapshot.ttl * 1e3) return null;
897
+ return this.snapshot;
898
+ }
899
+ readSnapshot() {
900
+ if (!this.storage) return null;
901
+ try {
902
+ const raw = this.storage.getItem(STORAGE_KEY);
903
+ return raw ? JSON.parse(raw) : null;
904
+ } catch {
905
+ return null;
906
+ }
907
+ }
908
+ persistSnapshot(snapshot) {
909
+ if (!this.storage) return;
910
+ try {
911
+ this.storage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
912
+ } catch {
913
+ }
914
+ }
915
+ readQueue() {
916
+ if (!this.storage) return [];
917
+ try {
918
+ const raw = this.storage.getItem(REPORT_QUEUE_KEY);
919
+ return raw ? JSON.parse(raw) : [];
920
+ } catch {
921
+ return [];
922
+ }
923
+ }
924
+ /** Stored decisions, pruned of expired entries. */
925
+ readDecisions() {
926
+ if (!this.storage) return {};
927
+ try {
928
+ const raw = this.storage.getItem(DECISIONS_KEY);
929
+ if (!raw) return {};
930
+ const parsed = JSON.parse(raw);
931
+ const now = this.now();
932
+ const live = {};
933
+ for (const [k, d] of Object.entries(parsed)) {
934
+ if (d && d.expiresAt > now) live[k] = d;
935
+ }
936
+ return live;
937
+ } catch {
938
+ return {};
939
+ }
940
+ }
941
+ writeDecisions(decisions) {
942
+ if (!this.storage) return;
943
+ try {
944
+ this.storage.setItem(DECISIONS_KEY, JSON.stringify(decisions));
945
+ } catch {
946
+ }
947
+ }
948
+ persistQueue() {
949
+ if (!this.storage) return;
950
+ try {
951
+ this.storage.setItem(REPORT_QUEUE_KEY, JSON.stringify(this.queue));
952
+ } catch {
953
+ }
954
+ }
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("&amp;") ? "&amp;" : "&" : "?";
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
+ }
371
1151
  export {
372
1152
  BanditAlgorithm,
373
1153
  BanditsClient as BanditClient,
374
1154
  BanditsClient,
375
1155
  ContentType,
1156
+ DEFAULT_HALF_LIFE_MS,
1157
+ DEFAULT_SESSION_TTL_MS,
1158
+ DEFAULT_WINDOW_MS,
1159
+ EMAIL_TOKEN_PARAM,
376
1160
  ExperimentStatus,
1161
+ ForgettingMode,
377
1162
  FunnelStatus,
1163
+ PLACEMENTS,
1164
+ Posterior,
1165
+ SESSION_CREATED_AT_KEY,
1166
+ SESSION_ID_KEY,
1167
+ STATIONARY,
1168
+ SnapshotClient,
1169
+ appendEmailToken,
378
1170
  buildSubdomainUrl,
379
1171
  calculateConfidenceInterval,
1172
+ extractDocumentSlots,
1173
+ extractEmailLinks,
1174
+ extractSlotNames,
380
1175
  extractSubdomain,
381
- generateId,
1176
+ generateId2 as generateId,
382
1177
  generateSubdomain,
383
1178
  getContentTypeCategory,
1179
+ getOrCreateSessionId,
1180
+ hashRecipient,
1181
+ impressionManifest,
1182
+ isSlotBinding,
384
1183
  isValidEmail,
385
1184
  isValidSubdomain,
1185
+ mintEmailToken,
1186
+ normalizeAlgorithm,
1187
+ normalizePosterior,
386
1188
  normalizeTreatmentWeights,
1189
+ pickArm,
1190
+ pickTreatment,
1191
+ renderEmail,
1192
+ renderEmailDocument,
1193
+ renderFromPlan,
1194
+ reportPlanImpressions,
1195
+ resolveForgetting,
1196
+ rewriteEmailLinks,
1197
+ sampleCombination,
387
1198
  sanitizeMetadata,
388
- validateTreatmentWeights
1199
+ shouldRefreshPlan,
1200
+ uniformFor,
1201
+ validateTreatmentWeights,
1202
+ verifyEmailToken
389
1203
  };
390
1204
  //# sourceMappingURL=index.js.map