@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/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.config.apiUrl}/events`;
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.config.apiUrl}/events/batch`, {
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
- * Record a funnel payout event.
166
- * Call this when the terminal conversion happens (e.g., purchase).
167
- *
168
- * @param funnelId - The funnel ID
169
- * @param userId - The user who converted
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.config.apiUrl}${endpoint}`;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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">&nbsp;</td></tr></table></td></tr>`;
671
+ case "spacer":
672
+ return `<tr><td style="height:${block.height ?? 24}px;font-size:0;line-height:0">&nbsp;</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("&amp;") ? "&amp;" : "&" : "?";
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
- validateTreatmentWeights
1146
+ shouldRefreshPlan,
1147
+ uniformFor,
1148
+ validateTreatmentWeights,
1149
+ verifyEmailToken
705
1150
  };
706
1151
  //# sourceMappingURL=index.js.map