@runbandit/sdk 1.0.0 → 2.0.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.d.ts CHANGED
@@ -50,10 +50,12 @@ interface Experiment {
50
50
  name: string;
51
51
  description?: string;
52
52
  algorithm: BanditAlgorithm;
53
+ forgetting?: ForgettingConfig;
53
54
  status: ExperimentStatus;
54
55
  contentType: ContentType;
55
56
  treatments: Treatment[];
56
57
  context?: Record<string, any>;
58
+ bundleId?: string;
57
59
  image?: string;
58
60
  emoji?: string;
59
61
  assignmentTtl: number;
@@ -106,13 +108,66 @@ interface VariantConfig {
106
108
  content: string;
107
109
  metadata?: TreatmentMetadata & Record<string, any>;
108
110
  }
111
+ /**
112
+ * Bandit algorithm families.
113
+ *
114
+ * Every family is Thompson-style: a Bayesian posterior per arm, sampled at
115
+ * selection time. How much of the past that posterior remembers is controlled
116
+ * separately, by `ForgettingMode`.
117
+ */
109
118
  declare enum BanditAlgorithm {
110
- EPSILON_GREEDY = "epsilon_greedy",
111
- UCB1 = "ucb1",
112
119
  THOMPSON_SAMPLING = "thompson_sampling",
113
- CONTEXTUAL_LINEAR = "contextual_linear",
114
- CONTEXTUAL_NEURAL = "contextual_neural"
120
+ CONTEXTUAL_LINEAR = "contextual_linear"
115
121
  }
122
+ /**
123
+ * How an algorithm forgets old observations.
124
+ *
125
+ * Standard bandits assume the arms are stationary: a conversion from six months
126
+ * ago counts exactly as much as one from this morning. Real treatments are
127
+ * restless - creative fatigues, seasons turn, traffic mix shifts - so the two
128
+ * non-stationary modes below down-weight or drop stale evidence.
129
+ *
130
+ * - NONE stationary; every observation counts forever
131
+ * - DISCOUNTED observation weight decays as 0.5^(age / halfLifeMs)
132
+ * - SLIDING_WINDOW observations outside the last `windowMs` are dropped entirely
133
+ *
134
+ * Both non-stationary modes measure age in wall-clock time rather than in
135
+ * observation count, so a quiet experiment forgets at the same rate as a busy
136
+ * one.
137
+ */
138
+ declare enum ForgettingMode {
139
+ NONE = "none",
140
+ DISCOUNTED = "discounted",
141
+ SLIDING_WINDOW = "sliding_window"
142
+ }
143
+ interface ForgettingConfig {
144
+ mode: ForgettingMode;
145
+ /** Half-life in milliseconds. DISCOUNTED only. */
146
+ halfLifeMs?: number;
147
+ /** Window length in milliseconds. SLIDING_WINDOW only. */
148
+ windowMs?: number;
149
+ }
150
+ /** Default half-life for DISCOUNTED: one week. */
151
+ declare const DEFAULT_HALF_LIFE_MS: number;
152
+ /** Default window for SLIDING_WINDOW: two weeks. */
153
+ declare const DEFAULT_WINDOW_MS: number;
154
+ /** The stationary default applied when an experiment has no forgetting config. */
155
+ declare const STATIONARY: ForgettingConfig;
156
+ /**
157
+ * Fill in the defaults for a partially-specified forgetting config.
158
+ * An unrecognised or missing mode degrades to NONE rather than throwing, so a
159
+ * stale row written by an older deploy still selects treatments.
160
+ */
161
+ declare function resolveForgetting(config?: Partial<ForgettingConfig> | null): ForgettingConfig;
162
+ /**
163
+ * Map a stored algorithm string onto a supported algorithm.
164
+ *
165
+ * Retired names (epsilon_greedy, ucb1) map onto standard Thompson Sampling,
166
+ * which reads the same successes/trials counters they did, so a running
167
+ * experiment keeps its learned history. contextual_neural maps onto
168
+ * contextual_linear, which is what it already fell back to at runtime.
169
+ */
170
+ declare function normalizeAlgorithm(algorithm: string | null | undefined): BanditAlgorithm;
116
171
  declare enum ContentType {
117
172
  PLAIN_TEXT = "plain_text",
118
173
  MARKDOWN = "markdown",
@@ -226,6 +281,71 @@ interface ResolvedIdentity {
226
281
  anonymousIds: string[];
227
282
  isIdentified: boolean;
228
283
  }
284
+ type BundleMode = 'factored' | 'joint';
285
+ interface Bundle {
286
+ id: string;
287
+ name: string;
288
+ description?: string;
289
+ status: ExperimentStatus;
290
+ mode: BundleMode;
291
+ funnelId?: string;
292
+ assignmentTtl: number;
293
+ conversionWindowMs: number;
294
+ companyId: string;
295
+ createdAt: Date;
296
+ updatedAt: Date;
297
+ }
298
+ interface BundleAssignmentRequest {
299
+ bundleId: string;
300
+ userId: string;
301
+ context?: ContextualFeatures;
302
+ sessionId?: string;
303
+ }
304
+ interface BundleSlotAssignment {
305
+ assignmentId: string;
306
+ experimentId: string;
307
+ treatmentId: string;
308
+ config: VariantConfig | Record<string, unknown>;
309
+ }
310
+ interface BundleAssignmentResponse {
311
+ bundleAssignmentId: string;
312
+ slots: Record<string, BundleSlotAssignment>;
313
+ }
314
+ /**
315
+ * One treatment's published allocation within a snapshot experiment.
316
+ *
317
+ * This is the wire contract between the snapshot publisher (dashboard API)
318
+ * and its only consumer (the SDK). Declared once here so both sides share
319
+ * the same shape instead of drifting apart.
320
+ */
321
+ interface SnapshotTreatment {
322
+ id: string;
323
+ name?: string;
324
+ config: unknown;
325
+ /** Probability the next visitor is assigned this arm. Sums to ~1 per experiment. */
326
+ pct: number;
327
+ }
328
+ interface SnapshotExperiment {
329
+ /**
330
+ * 'snapshot' means the client may decide locally from `pct`. 'network' means
331
+ * it must call /api/assignments, because the experiment's state cannot be
332
+ * reduced to a static distribution.
333
+ */
334
+ mode: 'snapshot' | 'network';
335
+ /**
336
+ * Assignment lifetime in SECONDS (the unit `assignments.expiresAt` uses).
337
+ * The client holds a decision this long so that repeated pageviews in one
338
+ * session report one impression, matching the server path's one assignment
339
+ * per (experiment, user).
340
+ */
341
+ assignmentTtl: number;
342
+ treatments: SnapshotTreatment[];
343
+ }
344
+ interface CompanySnapshot {
345
+ ttl: number;
346
+ generatedAt: number;
347
+ experiments: Record<string, SnapshotExperiment>;
348
+ }
229
349
 
230
350
  declare function generateId(): string;
231
351
  declare function validateTreatmentWeights(treatments: Treatment[]): boolean;
@@ -326,6 +446,8 @@ declare class BanditsClient {
326
446
  * @param value - The payout value (e.g., purchase amount in dollars)
327
447
  */
328
448
  trackFunnelReward(funnelId: string, userId: string, value: number): Promise<void>;
449
+ getBundle(bundleId: string, userId: string, context?: ContextualFeatures): Promise<BundleAssignmentResponse>;
450
+ trackBundleReward(bundleId: string, userId: string, value: number): Promise<void>;
329
451
  private generateId;
330
452
  private getOrCreateSessionId;
331
453
  private makeRequest;
@@ -333,4 +455,108 @@ declare class BanditsClient {
333
455
  destroy(): void;
334
456
  }
335
457
 
336
- export { type APIResponse, type Account, type AlgorithmResult, type ApiKey, type Assignment, type AssignmentRequest, BanditAlgorithm, BanditsClient as BanditClient, type BanditConfig, type BanditResult, BanditsClient, type BanditsClientConfig, type Company, ContentType, type ContextualFeatures, type CreateApiKeyRequest, type CreateApiKeyResponse, type CreateExperimentRequest, type Experiment, type ExperimentStats, ExperimentStatus, type Funnel, type FunnelRewardRequest, FunnelStatus, type IdentifyMergeReason, type IdentifyUserRequest, type IdentifyUserResponse, type ResolvedIdentity, type Session, type TrackNamedEventRequest, type Treatment, type TreatmentMetadata, type TreatmentStats, type User, type VariantConfig, buildSubdomainUrl, calculateConfidenceInterval, extractSubdomain, generateId, generateSubdomain, getContentTypeCategory, isValidEmail, isValidSubdomain, normalizeTreatmentWeights, sanitizeMetadata, validateTreatmentWeights };
458
+ interface SyncAssignment {
459
+ clientAssignmentId: string;
460
+ experimentId: string;
461
+ treatmentId: string;
462
+ config: unknown;
463
+ }
464
+ interface SnapshotClientConfig {
465
+ apiUrl: string;
466
+ publishableKey: string;
467
+ /** Defaults to window.localStorage. Injectable for tests and SSR. */
468
+ storage?: Storage;
469
+ /** Injectable clock. Defaults to Date.now. */
470
+ now?: () => number;
471
+ /** Session lifetime in ms. Must match the BanditsClient using the same storage. */
472
+ sessionTtl?: number;
473
+ /**
474
+ * A snapshot embedded in the page at render time. When present there is no
475
+ * cold start at all - the decision happens with zero network calls, which is
476
+ * the whole point of the design.
477
+ */
478
+ inlineSnapshot?: CompanySnapshot;
479
+ fetchImpl?: typeof fetch;
480
+ }
481
+ /**
482
+ * Pick an arm from a published allocation.
483
+ *
484
+ * One uniform draw against the cumulative distribution. This is equivalent to
485
+ * Thompson sampling because `pct` *is* the distribution Thompson sampling
486
+ * draws from - the Monte Carlo ran server-side, once, instead of here on every
487
+ * pageview.
488
+ */
489
+ declare function pickTreatment(treatments: SnapshotTreatment[], draw: number): SnapshotTreatment | null;
490
+ declare class SnapshotClient {
491
+ private config;
492
+ private storage;
493
+ private now;
494
+ private fetchImpl;
495
+ private snapshot;
496
+ private queue;
497
+ private sessionId;
498
+ private inFlight;
499
+ constructor(config: SnapshotClientConfig);
500
+ /**
501
+ * Decide an arm with no network call, or return null.
502
+ *
503
+ * Null means "this caller must fall back" - a cold cache, an expired one, an
504
+ * unknown experiment, or one whose state cannot be served from a static
505
+ * document. Returning null rather than the control is deliberate: the caller
506
+ * renders control itself, and no impression is reported for it. Reporting a
507
+ * forced-control impression would inflate the control arm's trials with
508
+ * decisions the bandit never made.
509
+ */
510
+ getAssignmentSync(experimentId: string, userId: string): SyncAssignment | null;
511
+ /** Fetch a fresh snapshot and cache it. Safe to call on every pageview. */
512
+ refresh(): Promise<void>;
513
+ /**
514
+ * Post queued impressions.
515
+ *
516
+ * The queue is only cleared on success. A failed flush leaves the reports in
517
+ * storage, so a pageview that happened offline is still counted when the
518
+ * next one succeeds - the trial really did occur, and dropping it would bias
519
+ * the arm's denominator downward.
520
+ */
521
+ flush(): Promise<void>;
522
+ private doFlush;
523
+ /** The session id these reports are attributed under. */
524
+ getSessionId(): string;
525
+ pendingReportCount(): number;
526
+ /** The cached snapshot, or null when absent or past its ttl. */
527
+ private freshSnapshot;
528
+ private readSnapshot;
529
+ private persistSnapshot;
530
+ private readQueue;
531
+ /** Stored decisions, pruned of expired entries. */
532
+ private readDecisions;
533
+ private writeDecisions;
534
+ private persistQueue;
535
+ }
536
+
537
+ /**
538
+ * Session identity, shared by every client in this package.
539
+ *
540
+ * Reward attribution joins `assignments` by `sessionId`, so a decision made by
541
+ * `SnapshotClient` and an event tracked by `BanditsClient` must agree on the
542
+ * id or the conversion never lands on the impression. One implementation,
543
+ * used by both, is the only way that rule stays true.
544
+ */
545
+ declare const SESSION_ID_KEY = "bandits_session_id";
546
+ declare const SESSION_CREATED_AT_KEY = "bandits_session_created_at";
547
+ /** 24 hours, in milliseconds. */
548
+ declare const DEFAULT_SESSION_TTL_MS = 86400000;
549
+ interface SessionOptions {
550
+ /** Where the session lives. Null (no browser storage) means ephemeral. */
551
+ storage?: Storage | null;
552
+ /** Injectable clock, in milliseconds. */
553
+ now?: () => number;
554
+ ttlMs?: number;
555
+ }
556
+ /**
557
+ * Return the current session id, minting and persisting a new one when there
558
+ * is none or the stored one has aged past `ttlMs`.
559
+ */
560
+ declare function getOrCreateSessionId(options?: SessionOptions): string;
561
+
562
+ export { type APIResponse, type Account, type AlgorithmResult, type ApiKey, type Assignment, type AssignmentRequest, BanditAlgorithm, BanditsClient as BanditClient, type BanditConfig, type BanditResult, BanditsClient, type BanditsClientConfig, type Bundle, type BundleAssignmentRequest, type BundleAssignmentResponse, type BundleMode, type BundleSlotAssignment, type Company, type CompanySnapshot, ContentType, type ContextualFeatures, type CreateApiKeyRequest, type CreateApiKeyResponse, type CreateExperimentRequest, DEFAULT_HALF_LIFE_MS, DEFAULT_SESSION_TTL_MS, DEFAULT_WINDOW_MS, type Experiment, type ExperimentStats, ExperimentStatus, type ForgettingConfig, ForgettingMode, type Funnel, type FunnelRewardRequest, FunnelStatus, type IdentifyMergeReason, type IdentifyUserRequest, type IdentifyUserResponse, type ResolvedIdentity, SESSION_CREATED_AT_KEY, SESSION_ID_KEY, STATIONARY, type Session, type SessionOptions, SnapshotClient, type SnapshotClientConfig, type SnapshotExperiment, type SnapshotTreatment, type SyncAssignment, type TrackNamedEventRequest, type Treatment, type TreatmentMetadata, type TreatmentStats, type User, type VariantConfig, buildSubdomainUrl, calculateConfidenceInterval, extractSubdomain, generateId, generateSubdomain, getContentTypeCategory, getOrCreateSessionId, isValidEmail, isValidSubdomain, normalizeAlgorithm, normalizeTreatmentWeights, pickTreatment, resolveForgetting, sanitizeMetadata, validateTreatmentWeights };
package/dist/index.js CHANGED
@@ -1,3 +1,38 @@
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
2
37
  var BanditsClient = class {
3
38
  config;
@@ -144,31 +179,43 @@ var BanditsClient = class {
144
179
  throw new Error(response.error || "Failed to record funnel reward");
145
180
  }
146
181
  }
147
- generateId() {
148
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
149
- return crypto.randomUUID();
182
+ async getBundle(bundleId, userId, context) {
183
+ const request = {
184
+ bundleId,
185
+ userId,
186
+ context,
187
+ sessionId: this.sessionId
188
+ };
189
+ const response = await this.makeRequest("/bundles/assignment", "POST", request);
190
+ if (!response.success || !response.data) {
191
+ throw new Error(response.error || "Failed to get bundle assignment");
150
192
  }
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);
193
+ const payload = response.data?.data ?? response.data;
194
+ if (!payload?.bundleAssignmentId || !payload?.slots) {
195
+ throw new Error("Invalid bundle assignment response");
196
+ }
197
+ return {
198
+ bundleAssignmentId: payload.bundleAssignmentId,
199
+ slots: payload.slots
200
+ };
201
+ }
202
+ async trackBundleReward(bundleId, userId, value) {
203
+ const response = await this.makeRequest(`/bundles/${bundleId}/reward`, "POST", {
204
+ userId,
205
+ value
154
206
  });
207
+ if (!response.success) {
208
+ throw new Error(response.error || "Failed to record bundle reward");
209
+ }
210
+ }
211
+ generateId() {
212
+ return generateId();
155
213
  }
156
214
  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;
170
- }
171
- return this.generateId();
215
+ return getOrCreateSessionId({
216
+ storage: defaultSessionStorage(),
217
+ ttlMs: this.config.sessionTtl
218
+ });
172
219
  }
173
220
  async makeRequest(endpoint, method, body, extraHeaders) {
174
221
  const url = `${this.config.apiUrl}${endpoint}`;
@@ -230,12 +277,46 @@ var ExperimentStatus;
230
277
  })(ExperimentStatus || (ExperimentStatus = {}));
231
278
  var BanditAlgorithm;
232
279
  (function(BanditAlgorithm2) {
233
- BanditAlgorithm2["EPSILON_GREEDY"] = "epsilon_greedy";
234
- BanditAlgorithm2["UCB1"] = "ucb1";
235
280
  BanditAlgorithm2["THOMPSON_SAMPLING"] = "thompson_sampling";
236
281
  BanditAlgorithm2["CONTEXTUAL_LINEAR"] = "contextual_linear";
237
- BanditAlgorithm2["CONTEXTUAL_NEURAL"] = "contextual_neural";
238
282
  })(BanditAlgorithm || (BanditAlgorithm = {}));
283
+ var ForgettingMode;
284
+ (function(ForgettingMode2) {
285
+ ForgettingMode2["NONE"] = "none";
286
+ ForgettingMode2["DISCOUNTED"] = "discounted";
287
+ ForgettingMode2["SLIDING_WINDOW"] = "sliding_window";
288
+ })(ForgettingMode || (ForgettingMode = {}));
289
+ var DEFAULT_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1e3;
290
+ var DEFAULT_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
291
+ var STATIONARY = { mode: ForgettingMode.NONE };
292
+ function resolveForgetting(config) {
293
+ switch (config?.mode) {
294
+ case ForgettingMode.DISCOUNTED:
295
+ return {
296
+ mode: ForgettingMode.DISCOUNTED,
297
+ halfLifeMs: config.halfLifeMs && config.halfLifeMs > 0 ? config.halfLifeMs : DEFAULT_HALF_LIFE_MS
298
+ };
299
+ case ForgettingMode.SLIDING_WINDOW:
300
+ return {
301
+ mode: ForgettingMode.SLIDING_WINDOW,
302
+ windowMs: config.windowMs && config.windowMs > 0 ? config.windowMs : DEFAULT_WINDOW_MS
303
+ };
304
+ default:
305
+ return STATIONARY;
306
+ }
307
+ }
308
+ var LEGACY_ALGORITHM_ALIASES = {
309
+ epsilon_greedy: BanditAlgorithm.THOMPSON_SAMPLING,
310
+ ucb1: BanditAlgorithm.THOMPSON_SAMPLING,
311
+ contextual_neural: BanditAlgorithm.CONTEXTUAL_LINEAR
312
+ };
313
+ function normalizeAlgorithm(algorithm) {
314
+ if (algorithm === BanditAlgorithm.THOMPSON_SAMPLING)
315
+ return BanditAlgorithm.THOMPSON_SAMPLING;
316
+ if (algorithm === BanditAlgorithm.CONTEXTUAL_LINEAR)
317
+ return BanditAlgorithm.CONTEXTUAL_LINEAR;
318
+ return LEGACY_ALGORITHM_ALIASES[algorithm ?? ""] ?? BanditAlgorithm.THOMPSON_SAMPLING;
319
+ }
239
320
  var ContentType;
240
321
  (function(ContentType2) {
241
322
  ContentType2["PLAIN_TEXT"] = "plain_text";
@@ -275,7 +356,7 @@ var FunnelStatus;
275
356
  })(FunnelStatus || (FunnelStatus = {}));
276
357
 
277
358
  // ../shared/dist/utils.js
278
- function generateId() {
359
+ function generateId2() {
279
360
  return Math.random().toString(36).substring(2) + Date.now().toString(36);
280
361
  }
281
362
  function validateTreatmentWeights(treatments) {
@@ -368,22 +449,257 @@ function buildSubdomainUrl(subdomain, baseDomain, path = "", protocol = "https")
368
449
  const url = `${protocol}://${subdomain}.${baseDomain}`;
369
450
  return path ? `${url}${path.startsWith("/") ? path : "/" + path}` : url;
370
451
  }
452
+
453
+ // src/snapshot-cache.ts
454
+ var STORAGE_KEY = "bandits_snapshot";
455
+ var REPORT_QUEUE_KEY = "bandits_pending_reports";
456
+ var DECISIONS_KEY = "bandits_client_assignments";
457
+ function pickTreatment(treatments, draw) {
458
+ if (treatments.length === 0) return null;
459
+ let cumulative = 0;
460
+ for (const treatment of treatments) {
461
+ cumulative += treatment.pct;
462
+ if (draw < cumulative) return treatment;
463
+ }
464
+ return treatments[treatments.length - 1];
465
+ }
466
+ var DEFAULT_ASSIGNMENT_TTL_SECONDS = 86400;
467
+ function randomId() {
468
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
469
+ let id = "";
470
+ for (let i = 0; i < 24; i++) {
471
+ id += chars.charAt(Math.floor(Math.random() * chars.length));
472
+ }
473
+ return id;
474
+ }
475
+ var SnapshotClient = class {
476
+ config;
477
+ storage;
478
+ now;
479
+ fetchImpl;
480
+ snapshot = null;
481
+ queue = [];
482
+ sessionId;
483
+ inFlight = null;
484
+ constructor(config) {
485
+ this.config = config;
486
+ this.now = config.now ?? (() => Date.now());
487
+ this.fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args));
488
+ this.storage = config.storage ?? (typeof window !== "undefined" && window.localStorage ? window.localStorage : null);
489
+ this.queue = this.readQueue();
490
+ this.sessionId = getOrCreateSessionId({
491
+ storage: this.storage ?? defaultSessionStorage(),
492
+ now: this.now,
493
+ ttlMs: config.sessionTtl
494
+ });
495
+ if (config.inlineSnapshot) {
496
+ this.snapshot = config.inlineSnapshot;
497
+ this.persistSnapshot(config.inlineSnapshot);
498
+ } else {
499
+ this.snapshot = this.readSnapshot();
500
+ }
501
+ }
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
+ getAssignmentSync(experimentId, userId) {
513
+ const snapshot = this.freshSnapshot();
514
+ if (!snapshot) return null;
515
+ const experiment = snapshot.experiments[experimentId];
516
+ if (!experiment || experiment.mode !== "snapshot") return null;
517
+ const key = `${experimentId}:${userId}`;
518
+ const decisions = this.readDecisions();
519
+ const stored = decisions[key];
520
+ if (stored && stored.expiresAt > this.now()) {
521
+ const held = experiment.treatments.find((t) => t.id === stored.treatmentId);
522
+ if (held) {
523
+ return {
524
+ clientAssignmentId: stored.clientAssignmentId,
525
+ experimentId,
526
+ treatmentId: held.id,
527
+ config: held.config
528
+ };
529
+ }
530
+ }
531
+ const treatment = pickTreatment(experiment.treatments, Math.random());
532
+ if (!treatment) return null;
533
+ const clientAssignmentId = randomId();
534
+ const ttlSeconds = experiment.assignmentTtl ?? DEFAULT_ASSIGNMENT_TTL_SECONDS;
535
+ decisions[key] = {
536
+ clientAssignmentId,
537
+ treatmentId: treatment.id,
538
+ // expiresAt is milliseconds here; assignmentTtl arrives in seconds.
539
+ expiresAt: this.now() + ttlSeconds * 1e3
540
+ };
541
+ this.writeDecisions(decisions);
542
+ this.queue.push({
543
+ clientAssignmentId,
544
+ experimentId,
545
+ treatmentId: treatment.id,
546
+ userId,
547
+ sessionId: this.sessionId
548
+ });
549
+ this.persistQueue();
550
+ return {
551
+ clientAssignmentId,
552
+ experimentId,
553
+ treatmentId: treatment.id,
554
+ config: treatment.config
555
+ };
556
+ }
557
+ /** Fetch a fresh snapshot and cache it. Safe to call on every pageview. */
558
+ async refresh() {
559
+ const response = await this.fetchImpl(`${this.config.apiUrl}/snapshot`, {
560
+ method: "GET",
561
+ headers: { "x-api-key": this.config.publishableKey }
562
+ });
563
+ if (!response.ok) return;
564
+ const snapshot = await response.json();
565
+ this.snapshot = snapshot;
566
+ this.persistSnapshot(snapshot);
567
+ }
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
+ async flush() {
577
+ if (this.inFlight) return this.inFlight;
578
+ this.inFlight = this.doFlush().finally(() => {
579
+ this.inFlight = null;
580
+ });
581
+ return this.inFlight;
582
+ }
583
+ async doFlush() {
584
+ if (this.queue.length === 0) return;
585
+ const reports = this.queue.slice(0, 1e3);
586
+ try {
587
+ const response = await this.fetchImpl(`${this.config.apiUrl}/assignments/report`, {
588
+ method: "POST",
589
+ headers: {
590
+ "content-type": "application/json",
591
+ "x-api-key": this.config.publishableKey
592
+ },
593
+ body: JSON.stringify({ reports }),
594
+ keepalive: true
595
+ });
596
+ if (!response.ok) return;
597
+ } catch {
598
+ return;
599
+ }
600
+ this.queue = this.queue.slice(reports.length);
601
+ this.persistQueue();
602
+ }
603
+ /** The session id these reports are attributed under. */
604
+ getSessionId() {
605
+ return this.sessionId;
606
+ }
607
+ pendingReportCount() {
608
+ return this.queue.length;
609
+ }
610
+ /** The cached snapshot, or null when absent or past its ttl. */
611
+ freshSnapshot() {
612
+ if (!this.snapshot) return null;
613
+ if (this.snapshot.ttl === 0) return null;
614
+ const ageMs = this.now() - this.snapshot.generatedAt;
615
+ if (ageMs > this.snapshot.ttl * 1e3) return null;
616
+ return this.snapshot;
617
+ }
618
+ readSnapshot() {
619
+ if (!this.storage) return null;
620
+ try {
621
+ const raw = this.storage.getItem(STORAGE_KEY);
622
+ return raw ? JSON.parse(raw) : null;
623
+ } catch {
624
+ return null;
625
+ }
626
+ }
627
+ persistSnapshot(snapshot) {
628
+ if (!this.storage) return;
629
+ try {
630
+ this.storage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
631
+ } catch {
632
+ }
633
+ }
634
+ readQueue() {
635
+ if (!this.storage) return [];
636
+ try {
637
+ const raw = this.storage.getItem(REPORT_QUEUE_KEY);
638
+ return raw ? JSON.parse(raw) : [];
639
+ } catch {
640
+ return [];
641
+ }
642
+ }
643
+ /** Stored decisions, pruned of expired entries. */
644
+ readDecisions() {
645
+ if (!this.storage) return {};
646
+ try {
647
+ const raw = this.storage.getItem(DECISIONS_KEY);
648
+ if (!raw) return {};
649
+ const parsed = JSON.parse(raw);
650
+ const now = this.now();
651
+ const live = {};
652
+ for (const [k, d] of Object.entries(parsed)) {
653
+ if (d && d.expiresAt > now) live[k] = d;
654
+ }
655
+ return live;
656
+ } catch {
657
+ return {};
658
+ }
659
+ }
660
+ writeDecisions(decisions) {
661
+ if (!this.storage) return;
662
+ try {
663
+ this.storage.setItem(DECISIONS_KEY, JSON.stringify(decisions));
664
+ } catch {
665
+ }
666
+ }
667
+ persistQueue() {
668
+ if (!this.storage) return;
669
+ try {
670
+ this.storage.setItem(REPORT_QUEUE_KEY, JSON.stringify(this.queue));
671
+ } catch {
672
+ }
673
+ }
674
+ };
371
675
  export {
372
676
  BanditAlgorithm,
373
677
  BanditsClient as BanditClient,
374
678
  BanditsClient,
375
679
  ContentType,
680
+ DEFAULT_HALF_LIFE_MS,
681
+ DEFAULT_SESSION_TTL_MS,
682
+ DEFAULT_WINDOW_MS,
376
683
  ExperimentStatus,
684
+ ForgettingMode,
377
685
  FunnelStatus,
686
+ SESSION_CREATED_AT_KEY,
687
+ SESSION_ID_KEY,
688
+ STATIONARY,
689
+ SnapshotClient,
378
690
  buildSubdomainUrl,
379
691
  calculateConfidenceInterval,
380
692
  extractSubdomain,
381
- generateId,
693
+ generateId2 as generateId,
382
694
  generateSubdomain,
383
695
  getContentTypeCategory,
696
+ getOrCreateSessionId,
384
697
  isValidEmail,
385
698
  isValidSubdomain,
699
+ normalizeAlgorithm,
386
700
  normalizeTreatmentWeights,
701
+ pickTreatment,
702
+ resolveForgetting,
387
703
  sanitizeMetadata,
388
704
  validateTreatmentWeights
389
705
  };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/client.ts","../../shared/src/types.ts","../../shared/src/utils.ts"],"sourcesContent":["import {\n AssignmentRequest,\n TrackNamedEventRequest,\n BanditResult,\n APIResponse,\n ContextualFeatures\n} from '@runbandit/shared';\n\nexport interface BanditsClientConfig {\n apiUrl: string;\n apiKey: string;\n timeout?: number;\n batchSize?: number;\n flushInterval?: number;\n sessionTtl?: number; // default 24 hours in ms\n}\n\nexport interface Assignment {\n assignmentId: string;\n treatmentId: string;\n experimentId: string;\n userId: string;\n config: any;\n}\n\nexport class BanditsClient {\n private config: BanditsClientConfig;\n private namedEventQueue: TrackNamedEventRequest[] = [];\n private sessionId: string;\n private flushTimer?: NodeJS.Timeout;\n\n constructor(config: BanditsClientConfig) {\n this.config = {\n timeout: 5000,\n batchSize: 100,\n flushInterval: 10000,\n sessionTtl: 86400000, // 24 hours in ms\n ...config\n };\n this.sessionId = this.getOrCreateSessionId();\n this.startBatchFlush();\n }\n\n async getAssignment(\n experimentId: string,\n userId: string,\n context?: ContextualFeatures\n ): Promise<Assignment> {\n const request: AssignmentRequest = {\n experimentId,\n userId,\n context,\n sessionId: this.sessionId\n };\n\n const response = await this.makeRequest<any>(\n '/assignments',\n 'POST',\n request\n );\n\n if (!response.success || !response.data) {\n throw new Error(response.error || 'Failed to get assignment');\n }\n\n // API returns { success, data: { assignmentId, treatmentId, treatment, confidence } }\n // makeRequest puts the raw JSON body in response.data, so unwrap one level.\n const assignment = response.data?.data ?? response.data;\n\n if (!assignment?.assignmentId) {\n throw new Error('Invalid assignment response');\n }\n\n return {\n assignmentId: assignment.assignmentId,\n treatmentId: assignment.treatmentId,\n experimentId,\n userId,\n config: assignment.treatment?.config || {}\n };\n }\n\n /**\n * Track a named event for session-based attribution (recommended)\n * Events are matched to bandits by sessionId — no assignmentId needed\n * Set immediate: true to fire synchronously (survives navigation)\n */\n trackNamedEvent(\n event: { name: string; category?: string; value?: number; metadata?: Record<string, any> },\n opts?: { immediate?: boolean }\n ): void {\n const request: TrackNamedEventRequest = {\n name: event.name,\n category: event.category || 'custom',\n value: event.value,\n metadata: event.metadata,\n sessionId: this.sessionId,\n };\n\n if (opts?.immediate) {\n // Fire now and survive navigation (e.g. CTA click -> route change)\n const url = `${this.config.apiUrl}/events`;\n try {\n void fetch(url, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.config.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(request),\n keepalive: true,\n });\n } catch {\n // best-effort\n }\n return;\n }\n\n this.namedEventQueue.push(request);\n if (this.namedEventQueue.length >= this.config.batchSize!) {\n this.flush();\n }\n }\n\n async flush(): Promise<void> {\n const namedEvents = [...this.namedEventQueue];\n this.namedEventQueue = [];\n if (namedEvents.length === 0) return;\n\n try {\n await fetch(`${this.config.apiUrl}/events/batch`, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.config.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ events: namedEvents }),\n keepalive: true,\n });\n } catch (error) {\n this.namedEventQueue.unshift(...namedEvents);\n throw error;\n }\n }\n\n /**\n * Link an anonymous user ID to a durable identity (e.g. email, account ID).\n * All historical assignments and events for the anonymous ID are re-attributed\n * to the identified user. Future calls to getAssignment() with either ID will\n * resolve to the same assignment.\n *\n * Requires admin authentication — the client must be configured with a\n * personal access token (PAT) instead of an API key, and companyId must\n * be provided. This is a management operation, not an SDK client operation.\n *\n * @param anonymousId - The temporary/anonymous user ID (e.g. crypto.randomUUID())\n * @param identifiedUserId - The permanent user ID (e.g. email, database ID)\n * @param companyId - The company ID (required for PAT auth)\n * @param mergeReason - Why the identification is happening\n */\n async identify(\n anonymousId: string,\n identifiedUserId: string,\n companyId: string,\n mergeReason?: 'user_signup' | 'email_verification' | 'social_login' | 'account_recovery' | 'manual'\n ): Promise<void> {\n const response = await this.makeRequest(\n '/identify',\n 'POST',\n {\n anonymousId,\n identifiedUserId,\n mergeReason: mergeReason || 'manual',\n },\n { 'x-company-id': companyId }\n );\n\n if (!response.success) {\n throw new Error(response.error || 'Failed to identify user');\n }\n }\n\n /**\n * Record a funnel payout event.\n * Call this when the terminal conversion happens (e.g., purchase).\n *\n * @param funnelId - The funnel ID\n * @param userId - The user who converted\n * @param value - The payout value (e.g., purchase amount in dollars)\n */\n async trackFunnelReward(\n funnelId: string,\n userId: string,\n value: number\n ): Promise<void> {\n const response = await this.makeRequest(\n `/funnels/${funnelId}/reward`,\n 'POST',\n { userId, value }\n );\n\n if (!response.success) {\n throw new Error(response.error || 'Failed to record funnel reward');\n }\n }\n\n private generateId(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);\n });\n }\n\n private getOrCreateSessionId(): string {\n if (typeof window !== 'undefined' && window.localStorage) {\n const stored = localStorage.getItem('bandits_session_id');\n const storedAt = localStorage.getItem('bandits_session_created_at');\n if (stored && storedAt) {\n const age = Date.now() - parseInt(storedAt, 10);\n if (age < this.config.sessionTtl!) {\n return stored;\n }\n }\n const id = this.generateId();\n localStorage.setItem('bandits_session_id', id);\n localStorage.setItem('bandits_session_created_at', String(Date.now()));\n return id;\n }\n return this.generateId();\n }\n\n private async makeRequest<T = any>(\n endpoint: string,\n method: 'GET' | 'POST' | 'PUT' | 'DELETE',\n body?: any,\n extraHeaders?: Record<string, string>\n ): Promise<APIResponse<T>> {\n const url = `${this.config.apiUrl}${endpoint}`;\n\n const headers: Record<string, string> = {\n 'Authorization': `Bearer ${this.config.apiKey}`,\n 'Content-Type': 'application/json',\n ...extraHeaders,\n };\n\n const requestConfig: RequestInit = {\n method,\n headers,\n signal: AbortSignal.timeout(this.config.timeout!)\n };\n\n if (body && method !== 'GET') {\n requestConfig.body = JSON.stringify(body);\n }\n\n try {\n const response = await fetch(url, requestConfig);\n const data = await response.json();\n \n return {\n success: response.ok,\n data: response.ok ? data as T : undefined,\n error: response.ok ? undefined : (data as any)?.message || 'Request failed',\n timestamp: new Date()\n };\n } catch (error) {\n return {\n success: false,\n error: error instanceof Error ? error.message : 'Network error',\n timestamp: new Date()\n };\n }\n }\n\n private startBatchFlush(): void {\n this.flushTimer = setInterval(() => {\n this.flush().catch(console.error);\n }, this.config.flushInterval);\n }\n\n destroy(): void {\n if (this.flushTimer) {\n clearInterval(this.flushTimer);\n }\n this.flush().catch(console.error);\n if (typeof window !== 'undefined' && window.localStorage) {\n localStorage.removeItem('bandits_session_id');\n localStorage.removeItem('bandits_session_created_at');\n }\n }\n}","export interface Company {\n id: string;\n name: string;\n subdomain?: string; // nullable for backwards compatibility with existing data\n domain?: string;\n plan: string;\n apiKey: string;\n status: string;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface User {\n id: string;\n email: string;\n name: string;\n image?: string;\n role: string;\n companyId: string | null;\n emailVerified?: boolean;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface Session {\n id: string;\n userId: string;\n token: string;\n expiresAt: Date;\n ipAddress?: string | null;\n userAgent?: string | null;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface Account {\n id: string;\n userId: string;\n provider: string;\n providerAccountId: string;\n accessToken?: string;\n refreshToken?: string;\n accessTokenExpiresAt?: Date;\n refreshTokenExpiresAt?: Date;\n scope?: string;\n idToken?: string;\n sessionState?: string;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface Experiment {\n id: string;\n name: string;\n description?: string;\n algorithm: BanditAlgorithm;\n status: ExperimentStatus;\n contentType: ContentType;\n treatments: Treatment[];\n context?: Record<string, any>;\n image?: string; // URL for uploaded image\n emoji?: string; // Emoji character as alternative to image\n assignmentTtl: number; // TTL in seconds for assignment expiration\n companyId: string;\n createdAt: Date;\n updatedAt: Date;\n endAt?: Date;\n}\n\nexport interface Treatment {\n id: string;\n experimentId: string;\n name: string;\n description?: string;\n config: VariantConfig;\n weight?: number;\n companyId: string;\n createdAt: Date;\n}\n\nexport interface AlgorithmResult {\n treatmentId: string;\n confidence: number;\n}\n\nexport interface BanditResult {\n assignmentId: string;\n treatmentId: string;\n confidence: number;\n metadata?: Record<string, any>;\n}\n\nexport interface ContextualFeatures {\n [key: string]: string | number | boolean;\n}\n\nexport enum ExperimentStatus {\n DRAFT = 'draft',\n ACTIVE = 'active',\n PAUSED = 'paused',\n COMPLETED = 'completed',\n ARCHIVED = 'archived'\n}\n\n\nexport interface TreatmentMetadata {\n strategy?: string; // e.g. \"social proof\", \"urgency\"\n tone?: string; // e.g. \"warm\", \"snarky\", \"deadpan corporate\"\n ctaStyle?: string; // e.g. \"direct\", \"soft question\", \"implied next step\"\n emotionalHook?: string; // e.g. \"fomo\", \"belonging\", \"righteous indignation\"\n reasoning?: string; // LLM's explanation of why it chose this approach\n tags?: string[];\n generated?: boolean;\n generatedAt?: string;\n}\n\nexport interface VariantConfig {\n content: string;\n metadata?: TreatmentMetadata & Record<string, any>;\n}\n\nexport enum BanditAlgorithm {\n EPSILON_GREEDY = 'epsilon_greedy',\n UCB1 = 'ucb1',\n THOMPSON_SAMPLING = 'thompson_sampling',\n CONTEXTUAL_LINEAR = 'contextual_linear',\n CONTEXTUAL_NEURAL = 'contextual_neural'\n}\n\nexport enum ContentType {\n PLAIN_TEXT = 'plain_text',\n MARKDOWN = 'markdown',\n HTML = 'html',\n CSS = 'css',\n JAVASCRIPT = 'javascript',\n JSON = 'json',\n IMAGE = 'image',\n VIDEO = 'video',\n AUDIO = 'audio',\n DOCUMENT = 'document',\n}\n\nexport function getContentTypeCategory(contentType: ContentType): 'text' | 'code' | 'file' {\n switch (contentType) {\n case ContentType.PLAIN_TEXT:\n case ContentType.MARKDOWN:\n return 'text';\n case ContentType.HTML:\n case ContentType.CSS:\n case ContentType.JAVASCRIPT:\n case ContentType.JSON:\n return 'code';\n case ContentType.IMAGE:\n case ContentType.VIDEO:\n case ContentType.AUDIO:\n case ContentType.DOCUMENT:\n return 'file';\n }\n}\n\nexport interface BanditConfig {\n algorithm: BanditAlgorithm;\n parameters: Record<string, any>;\n}\n\nexport interface ExperimentStats {\n totalAssignments: number;\n totalConversions: number;\n conversionRate: number;\n treatmentStats: Record<string, TreatmentStats>;\n}\n\nexport interface TreatmentStats {\n assignments: number;\n conversions: number;\n conversionRate: number;\n confidence: number;\n lift?: number;\n}\n\nexport interface APIResponse<T = any> {\n success: boolean;\n data?: T;\n error?: string;\n timestamp: Date;\n}\n\nexport interface CreateExperimentRequest {\n name: string;\n description?: string;\n algorithm: BanditAlgorithm;\n contentType: ContentType;\n algorithmConfig?: Record<string, any>;\n treatments?: Omit<Treatment, 'id' | 'experimentId' | 'createdAt'>[];\n}\n\nexport interface AssignmentRequest {\n experimentId: string;\n userId: string;\n context?: ContextualFeatures;\n sessionId?: string;\n}\n\n\nexport interface TrackNamedEventRequest {\n name: string;\n category?: string;\n value?: number;\n metadata?: Record<string, any>;\n sessionId: string;\n}\n\n// --- Funnel RL Types ---\n\nexport enum FunnelStatus {\n DRAFT = 'draft',\n ACTIVE = 'active',\n PAUSED = 'paused',\n COMPLETED = 'completed',\n}\n\nexport interface Funnel {\n id: string;\n name: string;\n description?: string;\n status: FunnelStatus;\n conversionWindowMs: number;\n companyId: string;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface FunnelRewardRequest {\n funnelId: string;\n userId: string;\n value: number;\n}\n\n// --- API Key Management Types ---\n\nexport interface ApiKey {\n id: string;\n name: string;\n keyPrefix: string;\n status: 'active' | 'revoked';\n createdBy: string;\n createdAt: Date;\n lastUsedAt?: Date;\n revokedAt?: Date;\n revokedBy?: string;\n}\n\nexport interface CreateApiKeyRequest {\n name: string;\n}\n\nexport interface CreateApiKeyResponse {\n id: string;\n key: string;\n name: string;\n}\n\n// --- Identity Resolution Types ---\n\nexport type IdentifyMergeReason =\n | 'user_signup'\n | 'email_verification'\n | 'social_login'\n | 'account_recovery'\n | 'manual';\n\nexport interface IdentifyUserRequest {\n anonymousId: string;\n identifiedUserId: string;\n mergeReason?: IdentifyMergeReason;\n}\n\nexport interface IdentifyUserResponse {\n anonymousId: string;\n identifiedUserId: string;\n alreadyMapped: boolean;\n}\n\nexport interface ResolvedIdentity {\n canonicalId: string;\n anonymousIds: string[];\n isIdentified: boolean;\n}\n","import { Treatment } from './types.js';\n\nexport function generateId(): string {\n return Math.random().toString(36).substring(2) + Date.now().toString(36);\n}\n\nexport function validateTreatmentWeights(treatments: Treatment[]): boolean {\n const totalWeight = treatments.reduce((sum, treatment) => sum + (treatment.weight || 1), 0);\n return totalWeight > 0;\n}\n\nexport function normalizeTreatmentWeights(treatments: Treatment[]): Treatment[] {\n const totalWeight = treatments.reduce((sum, treatment) => sum + (treatment.weight || 1), 0);\n \n return treatments.map(treatment => ({\n ...treatment,\n weight: (treatment.weight || 1) / totalWeight\n }));\n}\n\nexport function calculateConfidenceInterval(\n successes: number,\n trials: number,\n confidence: number = 0.95\n): { lower: number; upper: number } {\n if (trials === 0) return { lower: 0, upper: 0 };\n \n const z = confidence === 0.95 ? 1.96 : confidence === 0.99 ? 2.576 : 1.645;\n const p = successes / trials;\n const margin = z * Math.sqrt((p * (1 - p)) / trials);\n \n return {\n lower: Math.max(0, p - margin),\n upper: Math.min(1, p + margin)\n };\n}\n\nexport function isValidEmail(email: string): boolean {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n return emailRegex.test(email);\n}\n\nexport function sanitizeMetadata(metadata: Record<string, any>): Record<string, any> {\n const sanitized: Record<string, any> = {};\n\n for (const [key, value] of Object.entries(metadata)) {\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n sanitized[key] = value;\n } else if (value !== null && value !== undefined) {\n sanitized[key] = String(value);\n }\n }\n\n return sanitized;\n}\n\n/**\n * Generates a URL-safe subdomain from a company name\n * @param name Company name\n * @returns URL-safe subdomain\n */\nexport function generateSubdomain(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '') // Remove special characters\n .replace(/\\s+/g, '-') // Replace spaces with hyphens\n .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen\n .replace(/^-|-$/g, '') // Remove leading/trailing hyphens\n .substring(0, 63); // DNS label max length\n}\n\n/**\n * Validates if a subdomain is properly formatted\n * @param subdomain Subdomain to validate\n * @returns true if valid, false otherwise\n */\nexport function isValidSubdomain(subdomain: string): boolean {\n if (!subdomain || subdomain.length === 0 || subdomain.length > 63) {\n return false;\n }\n\n // Subdomain rules: lowercase letters, numbers, hyphens\n // Cannot start or end with hyphen\n // Cannot contain consecutive hyphens\n const subdomainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;\n\n // Reserved subdomains\n const reserved = [\n 'www', 'api', 'app', 'admin', 'dashboard', 'mail', 'ftp', 'localhost',\n 'staging', 'dev', 'test', 'demo', 'blog', 'docs', 'status', 'help',\n 'support', 'cdn', 'static', 'assets', 'secure', 'payment', 'pay'\n ];\n\n return subdomainRegex.test(subdomain) && !reserved.includes(subdomain);\n}\n\n/**\n * Extracts subdomain from hostname\n * @param hostname Full hostname (e.g., 'acme.example.com')\n * @param baseDomain Base domain (e.g., 'example.com')\n * @returns Subdomain or null if not found\n */\nexport function extractSubdomain(hostname: string, baseDomain: string): string | null {\n if (!hostname || !baseDomain) {\n return null;\n }\n\n // Remove port if present\n const cleanHostname = hostname.split(':')[0];\n\n // Check if hostname ends with base domain\n if (!cleanHostname.endsWith(baseDomain)) {\n return null;\n }\n\n // Extract subdomain part\n const subdomain = cleanHostname.slice(0, -(baseDomain.length + 1));\n\n // Return null if no subdomain or if it's www\n if (!subdomain || subdomain === 'www') {\n return null;\n }\n\n return subdomain;\n}\n\n/**\n * Builds full URL with subdomain\n * @param subdomain Subdomain\n * @param baseDomain Base domain\n * @param path Optional path\n * @param protocol Protocol (default: https)\n * @returns Full URL\n */\nexport function buildSubdomainUrl(\n subdomain: string,\n baseDomain: string,\n path: string = '',\n protocol: string = 'https'\n): string {\n const url = `${protocol}://${subdomain}.${baseDomain}`;\n return path ? `${url}${path.startsWith('/') ? path : '/' + path}` : url;\n}"],"mappings":"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","names":["ExperimentStatus","BanditAlgorithm","ContentType","FunnelStatus"]}
1
+ {"version":3,"sources":["../src/session.ts","../src/client.ts","../../shared/src/types.ts","../../shared/src/utils.ts","../src/snapshot-cache.ts"],"sourcesContent":["/**\n * Session identity, shared by every client in this package.\n *\n * Reward attribution joins `assignments` by `sessionId`, so a decision made by\n * `SnapshotClient` and an event tracked by `BanditsClient` must agree on the\n * id or the conversion never lands on the impression. One implementation,\n * used by both, is the only way that rule stays true.\n */\n\nexport const SESSION_ID_KEY = 'bandits_session_id';\nexport const SESSION_CREATED_AT_KEY = 'bandits_session_created_at';\n\n/** 24 hours, in milliseconds. */\nexport const DEFAULT_SESSION_TTL_MS = 86400000;\n\n/** A UUID v4, falling back to a Math.random shape where crypto is absent. */\nexport function generateId(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);\n });\n}\n\nexport interface SessionOptions {\n /** Where the session lives. Null (no browser storage) means ephemeral. */\n storage?: Storage | null;\n /** Injectable clock, in milliseconds. */\n now?: () => number;\n ttlMs?: number;\n}\n\n/**\n * Return the current session id, minting and persisting a new one when there\n * is none or the stored one has aged past `ttlMs`.\n */\nexport function getOrCreateSessionId(options: SessionOptions = {}): string {\n const { storage, now = () => Date.now(), ttlMs = DEFAULT_SESSION_TTL_MS } = options;\n if (!storage) return generateId();\n\n try {\n const stored = storage.getItem(SESSION_ID_KEY);\n const storedAt = storage.getItem(SESSION_CREATED_AT_KEY);\n if (stored && storedAt) {\n const age = now() - parseInt(storedAt, 10);\n if (age < ttlMs) return stored;\n }\n const id = generateId();\n storage.setItem(SESSION_ID_KEY, id);\n storage.setItem(SESSION_CREATED_AT_KEY, String(now()));\n return id;\n } catch {\n return generateId();\n }\n}\n\n/** The browser's localStorage, or null outside a browser. */\nexport function defaultSessionStorage(): Storage | null {\n return typeof window !== 'undefined' && window.localStorage ? window.localStorage : null;\n}\n","import {\n AssignmentRequest,\n TrackNamedEventRequest,\n BanditResult,\n APIResponse,\n ContextualFeatures,\n BundleAssignmentRequest,\n BundleAssignmentResponse,\n} from '@runbandit/shared';\nimport { defaultSessionStorage, generateId, getOrCreateSessionId } from './session.js';\n\nexport interface BanditsClientConfig {\n apiUrl: string;\n apiKey: string;\n timeout?: number;\n batchSize?: number;\n flushInterval?: number;\n sessionTtl?: number; // default 24 hours in ms\n}\n\nexport interface Assignment {\n assignmentId: string;\n treatmentId: string;\n experimentId: string;\n userId: string;\n config: any;\n}\n\nexport class BanditsClient {\n private config: BanditsClientConfig;\n private namedEventQueue: TrackNamedEventRequest[] = [];\n private sessionId: string;\n private flushTimer?: NodeJS.Timeout;\n\n constructor(config: BanditsClientConfig) {\n this.config = {\n timeout: 5000,\n batchSize: 100,\n flushInterval: 10000,\n sessionTtl: 86400000, // 24 hours in ms\n ...config\n };\n this.sessionId = this.getOrCreateSessionId();\n this.startBatchFlush();\n }\n\n async getAssignment(\n experimentId: string,\n userId: string,\n context?: ContextualFeatures\n ): Promise<Assignment> {\n const request: AssignmentRequest = {\n experimentId,\n userId,\n context,\n sessionId: this.sessionId\n };\n\n const response = await this.makeRequest<any>(\n '/assignments',\n 'POST',\n request\n );\n\n if (!response.success || !response.data) {\n throw new Error(response.error || 'Failed to get assignment');\n }\n\n // API returns { success, data: { assignmentId, treatmentId, treatment, confidence } }\n // makeRequest puts the raw JSON body in response.data, so unwrap one level.\n const assignment = response.data?.data ?? response.data;\n\n if (!assignment?.assignmentId) {\n throw new Error('Invalid assignment response');\n }\n\n return {\n assignmentId: assignment.assignmentId,\n treatmentId: assignment.treatmentId,\n experimentId,\n userId,\n config: assignment.treatment?.config || {}\n };\n }\n\n /**\n * Track a named event for session-based attribution (recommended)\n * Events are matched to bandits by sessionId — no assignmentId needed\n * Set immediate: true to fire synchronously (survives navigation)\n */\n trackNamedEvent(\n event: { name: string; category?: string; value?: number; metadata?: Record<string, any> },\n opts?: { immediate?: boolean }\n ): void {\n const request: TrackNamedEventRequest = {\n name: event.name,\n category: event.category || 'custom',\n value: event.value,\n metadata: event.metadata,\n sessionId: this.sessionId,\n };\n\n if (opts?.immediate) {\n // Fire now and survive navigation (e.g. CTA click -> route change)\n const url = `${this.config.apiUrl}/events`;\n try {\n void fetch(url, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.config.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(request),\n keepalive: true,\n });\n } catch {\n // best-effort\n }\n return;\n }\n\n this.namedEventQueue.push(request);\n if (this.namedEventQueue.length >= this.config.batchSize!) {\n this.flush();\n }\n }\n\n async flush(): Promise<void> {\n const namedEvents = [...this.namedEventQueue];\n this.namedEventQueue = [];\n if (namedEvents.length === 0) return;\n\n try {\n await fetch(`${this.config.apiUrl}/events/batch`, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.config.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ events: namedEvents }),\n keepalive: true,\n });\n } catch (error) {\n this.namedEventQueue.unshift(...namedEvents);\n throw error;\n }\n }\n\n /**\n * Link an anonymous user ID to a durable identity (e.g. email, account ID).\n * All historical assignments and events for the anonymous ID are re-attributed\n * to the identified user. Future calls to getAssignment() with either ID will\n * resolve to the same assignment.\n *\n * Requires admin authentication — the client must be configured with a\n * personal access token (PAT) instead of an API key, and companyId must\n * be provided. This is a management operation, not an SDK client operation.\n *\n * @param anonymousId - The temporary/anonymous user ID (e.g. crypto.randomUUID())\n * @param identifiedUserId - The permanent user ID (e.g. email, database ID)\n * @param companyId - The company ID (required for PAT auth)\n * @param mergeReason - Why the identification is happening\n */\n async identify(\n anonymousId: string,\n identifiedUserId: string,\n companyId: string,\n mergeReason?: 'user_signup' | 'email_verification' | 'social_login' | 'account_recovery' | 'manual'\n ): Promise<void> {\n const response = await this.makeRequest(\n '/identify',\n 'POST',\n {\n anonymousId,\n identifiedUserId,\n mergeReason: mergeReason || 'manual',\n },\n { 'x-company-id': companyId }\n );\n\n if (!response.success) {\n throw new Error(response.error || 'Failed to identify user');\n }\n }\n\n /**\n * Record a funnel payout event.\n * Call this when the terminal conversion happens (e.g., purchase).\n *\n * @param funnelId - The funnel ID\n * @param userId - The user who converted\n * @param value - The payout value (e.g., purchase amount in dollars)\n */\n async trackFunnelReward(\n funnelId: string,\n userId: string,\n value: number\n ): Promise<void> {\n const response = await this.makeRequest(\n `/funnels/${funnelId}/reward`,\n 'POST',\n { userId, value }\n );\n\n if (!response.success) {\n throw new Error(response.error || 'Failed to record funnel reward');\n }\n }\n\n async getBundle(\n bundleId: string,\n userId: string,\n context?: ContextualFeatures\n ): Promise<BundleAssignmentResponse> {\n const request: BundleAssignmentRequest = {\n bundleId,\n userId,\n context,\n sessionId: this.sessionId,\n };\n\n const response = await this.makeRequest<any>('/bundles/assignment', 'POST', request);\n\n if (!response.success || !response.data) {\n throw new Error(response.error || 'Failed to get bundle assignment');\n }\n\n // makeRequest wraps the raw JSON body in response.data; unwrap one level.\n const payload = response.data?.data ?? response.data;\n if (!payload?.bundleAssignmentId || !payload?.slots) {\n throw new Error('Invalid bundle assignment response');\n }\n\n return {\n bundleAssignmentId: payload.bundleAssignmentId,\n slots: payload.slots,\n };\n }\n\n async trackBundleReward(bundleId: string, userId: string, value: number): Promise<void> {\n const response = await this.makeRequest(`/bundles/${bundleId}/reward`, 'POST', {\n userId,\n value,\n });\n if (!response.success) {\n throw new Error(response.error || 'Failed to record bundle reward');\n }\n }\n\n private generateId(): string {\n return generateId();\n }\n\n private getOrCreateSessionId(): string {\n return getOrCreateSessionId({\n storage: defaultSessionStorage(),\n ttlMs: this.config.sessionTtl,\n });\n }\n\n private async makeRequest<T = any>(\n endpoint: string,\n method: 'GET' | 'POST' | 'PUT' | 'DELETE',\n body?: any,\n extraHeaders?: Record<string, string>\n ): Promise<APIResponse<T>> {\n const url = `${this.config.apiUrl}${endpoint}`;\n\n const headers: Record<string, string> = {\n 'Authorization': `Bearer ${this.config.apiKey}`,\n 'Content-Type': 'application/json',\n ...extraHeaders,\n };\n\n const requestConfig: RequestInit = {\n method,\n headers,\n signal: AbortSignal.timeout(this.config.timeout!)\n };\n\n if (body && method !== 'GET') {\n requestConfig.body = JSON.stringify(body);\n }\n\n try {\n const response = await fetch(url, requestConfig);\n const data = await response.json();\n \n return {\n success: response.ok,\n data: response.ok ? data as T : undefined,\n error: response.ok ? undefined : (data as any)?.message || 'Request failed',\n timestamp: new Date()\n };\n } catch (error) {\n return {\n success: false,\n error: error instanceof Error ? error.message : 'Network error',\n timestamp: new Date()\n };\n }\n }\n\n private startBatchFlush(): void {\n this.flushTimer = setInterval(() => {\n this.flush().catch(console.error);\n }, this.config.flushInterval);\n }\n\n destroy(): void {\n if (this.flushTimer) {\n clearInterval(this.flushTimer);\n }\n this.flush().catch(console.error);\n if (typeof window !== 'undefined' && window.localStorage) {\n localStorage.removeItem('bandits_session_id');\n localStorage.removeItem('bandits_session_created_at');\n }\n }\n}","export interface Company {\n id: string;\n name: string;\n subdomain?: string; // nullable for backwards compatibility with existing data\n domain?: string;\n plan: string;\n apiKey: string;\n status: string;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface User {\n id: string;\n email: string;\n name: string;\n image?: string;\n role: string;\n companyId: string | null;\n emailVerified?: boolean;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface Session {\n id: string;\n userId: string;\n token: string;\n expiresAt: Date;\n ipAddress?: string | null;\n userAgent?: string | null;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface Account {\n id: string;\n userId: string;\n provider: string;\n providerAccountId: string;\n accessToken?: string;\n refreshToken?: string;\n accessTokenExpiresAt?: Date;\n refreshTokenExpiresAt?: Date;\n scope?: string;\n idToken?: string;\n sessionState?: string;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface Experiment {\n id: string;\n name: string;\n description?: string;\n algorithm: BanditAlgorithm;\n forgetting?: ForgettingConfig;\n status: ExperimentStatus;\n contentType: ContentType;\n treatments: Treatment[];\n context?: Record<string, any>;\n bundleId?: string; // if set, experiment is a slot in a bundle (exclusive with funnelId)\n image?: string; // URL for uploaded image\n emoji?: string; // Emoji character as alternative to image\n assignmentTtl: number; // TTL in seconds for assignment expiration\n companyId: string;\n createdAt: Date;\n updatedAt: Date;\n endAt?: Date;\n}\n\nexport interface Treatment {\n id: string;\n experimentId: string;\n name: string;\n description?: string;\n config: VariantConfig;\n weight?: number;\n companyId: string;\n createdAt: Date;\n}\n\nexport interface AlgorithmResult {\n treatmentId: string;\n confidence: number;\n}\n\nexport interface BanditResult {\n assignmentId: string;\n treatmentId: string;\n confidence: number;\n metadata?: Record<string, any>;\n}\n\nexport interface ContextualFeatures {\n [key: string]: string | number | boolean;\n}\n\nexport enum ExperimentStatus {\n DRAFT = 'draft',\n ACTIVE = 'active',\n PAUSED = 'paused',\n COMPLETED = 'completed',\n ARCHIVED = 'archived'\n}\n\n\nexport interface TreatmentMetadata {\n strategy?: string; // e.g. \"social proof\", \"urgency\"\n tone?: string; // e.g. \"warm\", \"snarky\", \"deadpan corporate\"\n ctaStyle?: string; // e.g. \"direct\", \"soft question\", \"implied next step\"\n emotionalHook?: string; // e.g. \"fomo\", \"belonging\", \"righteous indignation\"\n reasoning?: string; // LLM's explanation of why it chose this approach\n tags?: string[];\n generated?: boolean;\n generatedAt?: string;\n}\n\nexport interface VariantConfig {\n content: string;\n metadata?: TreatmentMetadata & Record<string, any>;\n}\n\n/**\n * Bandit algorithm families.\n *\n * Every family is Thompson-style: a Bayesian posterior per arm, sampled at\n * selection time. How much of the past that posterior remembers is controlled\n * separately, by `ForgettingMode`.\n */\nexport enum BanditAlgorithm {\n THOMPSON_SAMPLING = 'thompson_sampling',\n CONTEXTUAL_LINEAR = 'contextual_linear'\n}\n\n/**\n * How an algorithm forgets old observations.\n *\n * Standard bandits assume the arms are stationary: a conversion from six months\n * ago counts exactly as much as one from this morning. Real treatments are\n * restless - creative fatigues, seasons turn, traffic mix shifts - so the two\n * non-stationary modes below down-weight or drop stale evidence.\n *\n * - NONE stationary; every observation counts forever\n * - DISCOUNTED observation weight decays as 0.5^(age / halfLifeMs)\n * - SLIDING_WINDOW observations outside the last `windowMs` are dropped entirely\n *\n * Both non-stationary modes measure age in wall-clock time rather than in\n * observation count, so a quiet experiment forgets at the same rate as a busy\n * one.\n */\nexport enum ForgettingMode {\n NONE = 'none',\n DISCOUNTED = 'discounted',\n SLIDING_WINDOW = 'sliding_window'\n}\n\nexport interface ForgettingConfig {\n mode: ForgettingMode;\n /** Half-life in milliseconds. DISCOUNTED only. */\n halfLifeMs?: number;\n /** Window length in milliseconds. SLIDING_WINDOW only. */\n windowMs?: number;\n}\n\n/** Default half-life for DISCOUNTED: one week. */\nexport const DEFAULT_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000;\n\n/** Default window for SLIDING_WINDOW: two weeks. */\nexport const DEFAULT_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;\n\n/** The stationary default applied when an experiment has no forgetting config. */\nexport const STATIONARY: ForgettingConfig = { mode: ForgettingMode.NONE };\n\n/**\n * Fill in the defaults for a partially-specified forgetting config.\n * An unrecognised or missing mode degrades to NONE rather than throwing, so a\n * stale row written by an older deploy still selects treatments.\n */\nexport function resolveForgetting(config?: Partial<ForgettingConfig> | null): ForgettingConfig {\n switch (config?.mode) {\n case ForgettingMode.DISCOUNTED:\n return {\n mode: ForgettingMode.DISCOUNTED,\n halfLifeMs: config.halfLifeMs && config.halfLifeMs > 0\n ? config.halfLifeMs\n : DEFAULT_HALF_LIFE_MS\n };\n case ForgettingMode.SLIDING_WINDOW:\n return {\n mode: ForgettingMode.SLIDING_WINDOW,\n windowMs: config.windowMs && config.windowMs > 0\n ? config.windowMs\n : DEFAULT_WINDOW_MS\n };\n default:\n return STATIONARY;\n }\n}\n\n/**\n * Algorithms that existed before the Thompson-only refactor, kept only so that\n * experiment rows written by older deploys still resolve on read.\n */\nconst LEGACY_ALGORITHM_ALIASES: Record<string, BanditAlgorithm> = {\n epsilon_greedy: BanditAlgorithm.THOMPSON_SAMPLING,\n ucb1: BanditAlgorithm.THOMPSON_SAMPLING,\n contextual_neural: BanditAlgorithm.CONTEXTUAL_LINEAR\n};\n\n/**\n * Map a stored algorithm string onto a supported algorithm.\n *\n * Retired names (epsilon_greedy, ucb1) map onto standard Thompson Sampling,\n * which reads the same successes/trials counters they did, so a running\n * experiment keeps its learned history. contextual_neural maps onto\n * contextual_linear, which is what it already fell back to at runtime.\n */\nexport function normalizeAlgorithm(algorithm: string | null | undefined): BanditAlgorithm {\n if (algorithm === BanditAlgorithm.THOMPSON_SAMPLING) return BanditAlgorithm.THOMPSON_SAMPLING;\n if (algorithm === BanditAlgorithm.CONTEXTUAL_LINEAR) return BanditAlgorithm.CONTEXTUAL_LINEAR;\n return LEGACY_ALGORITHM_ALIASES[algorithm ?? ''] ?? BanditAlgorithm.THOMPSON_SAMPLING;\n}\n\nexport enum ContentType {\n PLAIN_TEXT = 'plain_text',\n MARKDOWN = 'markdown',\n HTML = 'html',\n CSS = 'css',\n JAVASCRIPT = 'javascript',\n JSON = 'json',\n IMAGE = 'image',\n VIDEO = 'video',\n AUDIO = 'audio',\n DOCUMENT = 'document',\n}\n\nexport function getContentTypeCategory(contentType: ContentType): 'text' | 'code' | 'file' {\n switch (contentType) {\n case ContentType.PLAIN_TEXT:\n case ContentType.MARKDOWN:\n return 'text';\n case ContentType.HTML:\n case ContentType.CSS:\n case ContentType.JAVASCRIPT:\n case ContentType.JSON:\n return 'code';\n case ContentType.IMAGE:\n case ContentType.VIDEO:\n case ContentType.AUDIO:\n case ContentType.DOCUMENT:\n return 'file';\n }\n}\n\nexport interface BanditConfig {\n algorithm: BanditAlgorithm;\n parameters: Record<string, any>;\n}\n\nexport interface ExperimentStats {\n totalAssignments: number;\n totalConversions: number;\n conversionRate: number;\n treatmentStats: Record<string, TreatmentStats>;\n}\n\nexport interface TreatmentStats {\n assignments: number;\n conversions: number;\n conversionRate: number;\n confidence: number;\n lift?: number;\n}\n\nexport interface APIResponse<T = any> {\n success: boolean;\n data?: T;\n error?: string;\n timestamp: Date;\n}\n\nexport interface CreateExperimentRequest {\n name: string;\n description?: string;\n algorithm: BanditAlgorithm;\n contentType: ContentType;\n algorithmConfig?: Record<string, any>;\n treatments?: Omit<Treatment, 'id' | 'experimentId' | 'createdAt'>[];\n}\n\nexport interface AssignmentRequest {\n experimentId: string;\n userId: string;\n context?: ContextualFeatures;\n sessionId?: string;\n}\n\n\nexport interface TrackNamedEventRequest {\n name: string;\n category?: string;\n value?: number;\n metadata?: Record<string, any>;\n sessionId: string;\n}\n\n// --- Funnel RL Types ---\n\nexport enum FunnelStatus {\n DRAFT = 'draft',\n ACTIVE = 'active',\n PAUSED = 'paused',\n COMPLETED = 'completed',\n}\n\nexport interface Funnel {\n id: string;\n name: string;\n description?: string;\n status: FunnelStatus;\n conversionWindowMs: number;\n companyId: string;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface FunnelRewardRequest {\n funnelId: string;\n userId: string;\n value: number;\n}\n\n// --- API Key Management Types ---\n\nexport interface ApiKey {\n id: string;\n name: string;\n keyPrefix: string;\n status: 'active' | 'revoked';\n createdBy: string;\n createdAt: Date;\n lastUsedAt?: Date;\n revokedAt?: Date;\n revokedBy?: string;\n}\n\nexport interface CreateApiKeyRequest {\n name: string;\n}\n\nexport interface CreateApiKeyResponse {\n id: string;\n key: string;\n name: string;\n}\n\n// --- Identity Resolution Types ---\n\nexport type IdentifyMergeReason =\n | 'user_signup'\n | 'email_verification'\n | 'social_login'\n | 'account_recovery'\n | 'manual';\n\nexport interface IdentifyUserRequest {\n anonymousId: string;\n identifiedUserId: string;\n mergeReason?: IdentifyMergeReason;\n}\n\nexport interface IdentifyUserResponse {\n anonymousId: string;\n identifiedUserId: string;\n alreadyMapped: boolean;\n}\n\nexport interface ResolvedIdentity {\n canonicalId: string;\n anonymousIds: string[];\n isIdentified: boolean;\n}\n\n// --- Bundle Types ---\n\nexport type BundleMode = 'factored' | 'joint';\n\nexport interface Bundle {\n id: string;\n name: string;\n description?: string;\n status: ExperimentStatus; // reuse draft/active/paused/completed\n mode: BundleMode; // 'factored' default; 'joint' out of v1 scope\n funnelId?: string; // if set, bundle is a funnel member (learning owned by the funnel)\n assignmentTtl: number; // seconds; shared TTL for the whole bundle's page\n conversionWindowMs: number; // standalone reward window; default 604800000 (7 days)\n companyId: string;\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface BundleAssignmentRequest {\n bundleId: string;\n userId: string;\n context?: ContextualFeatures;\n sessionId?: string;\n}\n\nexport interface BundleSlotAssignment {\n assignmentId: string;\n experimentId: string;\n treatmentId: string;\n config: VariantConfig | Record<string, unknown>;\n}\n\nexport interface BundleAssignmentResponse {\n bundleAssignmentId: string;\n slots: Record<string, BundleSlotAssignment>;\n}\n\n/**\n * One treatment's published allocation within a snapshot experiment.\n *\n * This is the wire contract between the snapshot publisher (dashboard API)\n * and its only consumer (the SDK). Declared once here so both sides share\n * the same shape instead of drifting apart.\n */\nexport interface SnapshotTreatment {\n id: string;\n name?: string;\n config: unknown;\n /** Probability the next visitor is assigned this arm. Sums to ~1 per experiment. */\n pct: number;\n}\n\nexport interface SnapshotExperiment {\n /**\n * 'snapshot' means the client may decide locally from `pct`. 'network' means\n * it must call /api/assignments, because the experiment's state cannot be\n * reduced to a static distribution.\n */\n mode: 'snapshot' | 'network';\n /**\n * Assignment lifetime in SECONDS (the unit `assignments.expiresAt` uses).\n * The client holds a decision this long so that repeated pageviews in one\n * session report one impression, matching the server path's one assignment\n * per (experiment, user).\n */\n assignmentTtl: number;\n treatments: SnapshotTreatment[];\n}\n\nexport interface CompanySnapshot {\n ttl: number;\n generatedAt: number;\n experiments: Record<string, SnapshotExperiment>;\n}\n","import { Treatment } from './types.js';\n\nexport function generateId(): string {\n return Math.random().toString(36).substring(2) + Date.now().toString(36);\n}\n\nexport function validateTreatmentWeights(treatments: Treatment[]): boolean {\n const totalWeight = treatments.reduce((sum, treatment) => sum + (treatment.weight || 1), 0);\n return totalWeight > 0;\n}\n\nexport function normalizeTreatmentWeights(treatments: Treatment[]): Treatment[] {\n const totalWeight = treatments.reduce((sum, treatment) => sum + (treatment.weight || 1), 0);\n \n return treatments.map(treatment => ({\n ...treatment,\n weight: (treatment.weight || 1) / totalWeight\n }));\n}\n\nexport function calculateConfidenceInterval(\n successes: number,\n trials: number,\n confidence: number = 0.95\n): { lower: number; upper: number } {\n if (trials === 0) return { lower: 0, upper: 0 };\n \n const z = confidence === 0.95 ? 1.96 : confidence === 0.99 ? 2.576 : 1.645;\n const p = successes / trials;\n const margin = z * Math.sqrt((p * (1 - p)) / trials);\n \n return {\n lower: Math.max(0, p - margin),\n upper: Math.min(1, p + margin)\n };\n}\n\nexport function isValidEmail(email: string): boolean {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n return emailRegex.test(email);\n}\n\nexport function sanitizeMetadata(metadata: Record<string, any>): Record<string, any> {\n const sanitized: Record<string, any> = {};\n\n for (const [key, value] of Object.entries(metadata)) {\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n sanitized[key] = value;\n } else if (value !== null && value !== undefined) {\n sanitized[key] = String(value);\n }\n }\n\n return sanitized;\n}\n\n/**\n * Generates a URL-safe subdomain from a company name\n * @param name Company name\n * @returns URL-safe subdomain\n */\nexport function generateSubdomain(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '') // Remove special characters\n .replace(/\\s+/g, '-') // Replace spaces with hyphens\n .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen\n .replace(/^-|-$/g, '') // Remove leading/trailing hyphens\n .substring(0, 63); // DNS label max length\n}\n\n/**\n * Validates if a subdomain is properly formatted\n * @param subdomain Subdomain to validate\n * @returns true if valid, false otherwise\n */\nexport function isValidSubdomain(subdomain: string): boolean {\n if (!subdomain || subdomain.length === 0 || subdomain.length > 63) {\n return false;\n }\n\n // Subdomain rules: lowercase letters, numbers, hyphens\n // Cannot start or end with hyphen\n // Cannot contain consecutive hyphens\n const subdomainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;\n\n // Reserved subdomains\n const reserved = [\n 'www', 'api', 'app', 'admin', 'dashboard', 'mail', 'ftp', 'localhost',\n 'staging', 'dev', 'test', 'demo', 'blog', 'docs', 'status', 'help',\n 'support', 'cdn', 'static', 'assets', 'secure', 'payment', 'pay'\n ];\n\n return subdomainRegex.test(subdomain) && !reserved.includes(subdomain);\n}\n\n/**\n * Extracts subdomain from hostname\n * @param hostname Full hostname (e.g., 'acme.example.com')\n * @param baseDomain Base domain (e.g., 'example.com')\n * @returns Subdomain or null if not found\n */\nexport function extractSubdomain(hostname: string, baseDomain: string): string | null {\n if (!hostname || !baseDomain) {\n return null;\n }\n\n // Remove port if present\n const cleanHostname = hostname.split(':')[0];\n\n // Check if hostname ends with base domain\n if (!cleanHostname.endsWith(baseDomain)) {\n return null;\n }\n\n // Extract subdomain part\n const subdomain = cleanHostname.slice(0, -(baseDomain.length + 1));\n\n // Return null if no subdomain or if it's www\n if (!subdomain || subdomain === 'www') {\n return null;\n }\n\n return subdomain;\n}\n\n/**\n * Builds full URL with subdomain\n * @param subdomain Subdomain\n * @param baseDomain Base domain\n * @param path Optional path\n * @param protocol Protocol (default: https)\n * @returns Full URL\n */\nexport function buildSubdomainUrl(\n subdomain: string,\n baseDomain: string,\n path: string = '',\n protocol: string = 'https'\n): string {\n const url = `${protocol}://${subdomain}.${baseDomain}`;\n return path ? `${url}${path.startsWith('/') ? path : '/' + path}` : url;\n}","export type {\n SnapshotTreatment,\n SnapshotExperiment,\n CompanySnapshot,\n} from '@runbandit/shared';\nimport type { CompanySnapshot, SnapshotTreatment } from '@runbandit/shared';\nimport { defaultSessionStorage, getOrCreateSessionId } from './session.js';\n\nexport interface SyncAssignment {\n clientAssignmentId: string;\n experimentId: string;\n treatmentId: string;\n config: unknown;\n}\n\ninterface PendingReport {\n clientAssignmentId: string;\n experimentId: string;\n treatmentId: string;\n userId: string;\n /**\n * Reward attribution joins assignments by sessionId. Omitting it means a\n * snapshot-served impression can never convert: armTrials grows while\n * armSuccesses stays at zero and the next published allocation degenerates.\n */\n sessionId: string;\n}\n\nexport interface SnapshotClientConfig {\n apiUrl: string;\n publishableKey: string;\n /** Defaults to window.localStorage. Injectable for tests and SSR. */\n storage?: Storage;\n /** Injectable clock. Defaults to Date.now. */\n now?: () => number;\n /** Session lifetime in ms. Must match the BanditsClient using the same storage. */\n sessionTtl?: number;\n /**\n * A snapshot embedded in the page at render time. When present there is no\n * cold start at all - the decision happens with zero network calls, which is\n * the whole point of the design.\n */\n inlineSnapshot?: CompanySnapshot;\n fetchImpl?: typeof fetch;\n}\n\nconst STORAGE_KEY = 'bandits_snapshot';\nconst REPORT_QUEUE_KEY = 'bandits_pending_reports';\nconst DECISIONS_KEY = 'bandits_client_assignments';\n\n/** A decision already made and already reported. `expiresAt` is in ms. */\ninterface StoredDecision {\n clientAssignmentId: string;\n treatmentId: string;\n expiresAt: number;\n}\n\n/**\n * Pick an arm from a published allocation.\n *\n * One uniform draw against the cumulative distribution. This is equivalent to\n * Thompson sampling because `pct` *is* the distribution Thompson sampling\n * draws from - the Monte Carlo ran server-side, once, instead of here on every\n * pageview.\n */\nexport function pickTreatment(\n treatments: SnapshotTreatment[],\n draw: number\n): SnapshotTreatment | null {\n if (treatments.length === 0) return null;\n let cumulative = 0;\n for (const treatment of treatments) {\n cumulative += treatment.pct;\n if (draw < cumulative) return treatment;\n }\n // Falls through when the probabilities sum marginally below 1 (they are\n // Monte Carlo estimates). The last arm absorbs the remainder.\n return treatments[treatments.length - 1];\n}\n\n/** Fallback assignment lifetime in seconds, matching the server's. */\nconst DEFAULT_ASSIGNMENT_TTL_SECONDS = 86400;\n\nfunction randomId(): string {\n const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';\n let id = '';\n for (let i = 0; i < 24; i++) {\n id += chars.charAt(Math.floor(Math.random() * chars.length));\n }\n return id;\n}\n\nexport class SnapshotClient {\n private config: SnapshotClientConfig;\n private storage: Storage | null;\n private now: () => number;\n private fetchImpl: typeof fetch;\n private snapshot: CompanySnapshot | null = null;\n private queue: PendingReport[] = [];\n private sessionId: string;\n private inFlight: Promise<void> | null = null;\n\n constructor(config: SnapshotClientConfig) {\n this.config = config;\n this.now = config.now ?? (() => Date.now());\n this.fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args));\n this.storage =\n config.storage ??\n (typeof window !== 'undefined' && window.localStorage ? window.localStorage : null);\n\n this.queue = this.readQueue();\n // Deliberately the same storage keys BanditsClient uses, so a page running\n // both clients reports one session and conversions attribute.\n this.sessionId = getOrCreateSessionId({\n storage: this.storage ?? defaultSessionStorage(),\n now: this.now,\n ttlMs: config.sessionTtl,\n });\n\n if (config.inlineSnapshot) {\n this.snapshot = config.inlineSnapshot;\n this.persistSnapshot(config.inlineSnapshot);\n } else {\n this.snapshot = this.readSnapshot();\n }\n }\n\n /**\n * Decide an arm with no network call, or return null.\n *\n * Null means \"this caller must fall back\" - a cold cache, an expired one, an\n * unknown experiment, or one whose state cannot be served from a static\n * document. Returning null rather than the control is deliberate: the caller\n * renders control itself, and no impression is reported for it. Reporting a\n * forced-control impression would inflate the control arm's trials with\n * decisions the bandit never made.\n */\n getAssignmentSync(experimentId: string, userId: string): SyncAssignment | null {\n const snapshot = this.freshSnapshot();\n if (!snapshot) return null;\n\n const experiment = snapshot.experiments[experimentId];\n if (!experiment || experiment.mode !== 'snapshot') return null;\n\n // A decision already made for this visitor is the same impression, not a\n // new one. Returning it unchanged - and queueing nothing - keeps the\n // client path counting one trial per visitor per TTL, the way the server\n // path's (experimentId, userId) dedup does. Without it, five pageviews\n // would be five trials against at most one success, biasing every arm's\n // posterior downward and unevenly.\n const key = `${experimentId}:${userId}`;\n const decisions = this.readDecisions();\n const stored = decisions[key];\n if (stored && stored.expiresAt > this.now()) {\n const held = experiment.treatments.find((t) => t.id === stored.treatmentId);\n // If the held arm is gone from the snapshot we have nothing to render,\n // so fall through and decide again.\n if (held) {\n return {\n clientAssignmentId: stored.clientAssignmentId,\n experimentId,\n treatmentId: held.id,\n config: held.config,\n };\n }\n }\n\n const treatment = pickTreatment(experiment.treatments, Math.random());\n if (!treatment) return null;\n\n const clientAssignmentId = randomId();\n const ttlSeconds = experiment.assignmentTtl ?? DEFAULT_ASSIGNMENT_TTL_SECONDS;\n decisions[key] = {\n clientAssignmentId,\n treatmentId: treatment.id,\n // expiresAt is milliseconds here; assignmentTtl arrives in seconds.\n expiresAt: this.now() + ttlSeconds * 1000,\n };\n this.writeDecisions(decisions);\n\n this.queue.push({\n clientAssignmentId,\n experimentId,\n treatmentId: treatment.id,\n userId,\n sessionId: this.sessionId,\n });\n this.persistQueue();\n\n return {\n clientAssignmentId,\n experimentId,\n treatmentId: treatment.id,\n config: treatment.config,\n };\n }\n\n /** Fetch a fresh snapshot and cache it. Safe to call on every pageview. */\n async refresh(): Promise<void> {\n const response = await this.fetchImpl(`${this.config.apiUrl}/snapshot`, {\n method: 'GET',\n headers: { 'x-api-key': this.config.publishableKey },\n });\n if (!response.ok) return;\n const snapshot = (await response.json()) as CompanySnapshot;\n this.snapshot = snapshot;\n this.persistSnapshot(snapshot);\n }\n\n /**\n * Post queued impressions.\n *\n * The queue is only cleared on success. A failed flush leaves the reports in\n * storage, so a pageview that happened offline is still counted when the\n * next one succeeds - the trial really did occur, and dropping it would bias\n * the arm's denominator downward.\n */\n async flush(): Promise<void> {\n // Re-entrancy guard: overlapping callers (e.g. a `visibilitychange` flush\n // racing a `pagehide` flush) must not both snapshot the queue - the\n // second snapshot would slice off reports the first flush never sent,\n // losing them. Callers already in flight get the same in-progress promise.\n if (this.inFlight) return this.inFlight;\n this.inFlight = this.doFlush().finally(() => {\n this.inFlight = null;\n });\n return this.inFlight;\n }\n\n private async doFlush(): Promise<void> {\n if (this.queue.length === 0) return;\n // The server rejects batches over 1000 with a 400. Cap what we post per\n // call so a queue that grew past the limit (bad connection, sustained\n // 503s) doesn't deadlock itself: a 400 is `!response.ok`, so an uncapped\n // post would never clear and every future flush would 400 forever.\n const reports = this.queue.slice(0, 1000);\n\n try {\n const response = await this.fetchImpl(`${this.config.apiUrl}/assignments/report`, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'x-api-key': this.config.publishableKey,\n },\n body: JSON.stringify({ reports }),\n keepalive: true,\n });\n if (!response.ok) return;\n } catch {\n return;\n }\n\n this.queue = this.queue.slice(reports.length);\n this.persistQueue();\n }\n\n /** The session id these reports are attributed under. */\n getSessionId(): string {\n return this.sessionId;\n }\n\n pendingReportCount(): number {\n return this.queue.length;\n }\n\n /** The cached snapshot, or null when absent or past its ttl. */\n private freshSnapshot(): CompanySnapshot | null {\n if (!this.snapshot) return null;\n // ttl 0 is demo mode: never serve from cache, always go back for a fresh\n // allocation.\n if (this.snapshot.ttl === 0) return null;\n const ageMs = this.now() - this.snapshot.generatedAt;\n if (ageMs > this.snapshot.ttl * 1000) return null;\n return this.snapshot;\n }\n\n private readSnapshot(): CompanySnapshot | null {\n if (!this.storage) return null;\n try {\n const raw = this.storage.getItem(STORAGE_KEY);\n return raw ? (JSON.parse(raw) as CompanySnapshot) : null;\n } catch {\n return null;\n }\n }\n\n private persistSnapshot(snapshot: CompanySnapshot): void {\n if (!this.storage) return;\n try {\n this.storage.setItem(STORAGE_KEY, JSON.stringify(snapshot));\n } catch {}\n }\n\n private readQueue(): PendingReport[] {\n if (!this.storage) return [];\n try {\n const raw = this.storage.getItem(REPORT_QUEUE_KEY);\n return raw ? (JSON.parse(raw) as PendingReport[]) : [];\n } catch {\n return [];\n }\n }\n\n /** Stored decisions, pruned of expired entries. */\n private readDecisions(): Record<string, StoredDecision> {\n if (!this.storage) return {};\n try {\n const raw = this.storage.getItem(DECISIONS_KEY);\n if (!raw) return {};\n const parsed = JSON.parse(raw) as Record<string, StoredDecision>;\n const now = this.now();\n const live: Record<string, StoredDecision> = {};\n for (const [k, d] of Object.entries(parsed)) {\n if (d && d.expiresAt > now) live[k] = d;\n }\n return live;\n } catch {\n return {};\n }\n }\n\n private writeDecisions(decisions: Record<string, StoredDecision>): void {\n if (!this.storage) return;\n try {\n this.storage.setItem(DECISIONS_KEY, JSON.stringify(decisions));\n } catch {}\n }\n\n private persistQueue(): void {\n if (!this.storage) return;\n try {\n this.storage.setItem(REPORT_QUEUE_KEY, JSON.stringify(this.queue));\n } catch {}\n }\n}\n"],"mappings":"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","names":["ExperimentStatus","BanditAlgorithm","ForgettingMode","ContentType","FunnelStatus","generateId"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runbandit/sdk",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -19,11 +19,13 @@
19
19
  "scripts": {
20
20
  "build": "tsup",
21
21
  "dev": "tsup --watch",
22
- "clean": "rm -rf dist"
22
+ "clean": "rm -rf dist",
23
+ "test": "vitest run"
23
24
  },
24
25
  "devDependencies": {
25
26
  "@runbandit/shared": "workspace:*",
26
27
  "tsup": "^8.0.0",
27
- "typescript": "^5.0.0"
28
+ "typescript": "^5.0.0",
29
+ "vitest": "^4.0.18"
28
30
  }
29
31
  }