@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/README.md +3 -1
- package/dist/index.d.ts +814 -5
- package/dist/index.js +844 -30
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
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.
|
|
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.
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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.
|
|
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
|
|
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, "&").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
|
+
|
|
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("&") ? "&" : "&" : "?";
|
|
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
|
-
|
|
1199
|
+
shouldRefreshPlan,
|
|
1200
|
+
uniformFor,
|
|
1201
|
+
validateTreatmentWeights,
|
|
1202
|
+
verifyEmailToken
|
|
389
1203
|
};
|
|
390
1204
|
//# sourceMappingURL=index.js.map
|