@avsbhq/node 0.3.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,17 +1,17 @@
1
- // src/AvsBServer.ts
2
- import { evaluateFlag } from "@avsbhq/js";
3
-
4
- // src/logger.ts
5
- var noopLogger = {
6
- debug() {
7
- },
8
- info() {
9
- },
10
- warn() {
11
- },
12
- error() {
13
- }
14
- };
1
+ // src/AvsbServer.ts
2
+ import { evaluateFlag } from "@avsbhq/core/engine";
3
+ import {
4
+ toLegacyEvalContext,
5
+ createFlag,
6
+ notFoundFlag as notFoundFlag2,
7
+ mapLegacySource,
8
+ checkExpectedType,
9
+ findDatafileFlag,
10
+ contextSummary,
11
+ privateAttributesFrom,
12
+ bucketingKey,
13
+ noopLogger
14
+ } from "@avsbhq/core";
15
15
 
16
16
  // src/decisionLog.ts
17
17
  function createDecisionLog() {
@@ -20,6 +20,42 @@ function createDecisionLog() {
20
20
  }
21
21
 
22
22
  // src/serverTracker.ts
23
+ import { randomUUID } from "crypto";
24
+
25
+ // src/reconnectBackoff.ts
26
+ var BASE_DELAY_MS = 1e3;
27
+ var MAX_DELAY_MS = 6e4;
28
+ var MIN_DELAY_MS = 500;
29
+ var JITTER = 0.2;
30
+ function nextBackoff(prev, retryAfterMs) {
31
+ const attempt = (prev?.attempt ?? 0) + 1;
32
+ if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
33
+ return { attempt, delayMs: retryAfterMs };
34
+ }
35
+ const base = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (attempt - 1));
36
+ const jitter = base * JITTER * (Math.random() * 2 - 1);
37
+ const delayMs = Math.max(MIN_DELAY_MS, Math.round(base + jitter));
38
+ return { attempt, delayMs };
39
+ }
40
+ function resetBackoff() {
41
+ return null;
42
+ }
43
+ function parseRetryAfter(headerValue) {
44
+ if (!headerValue) return null;
45
+ const trimmed = headerValue.trim();
46
+ if (!trimmed) return null;
47
+ const n = Number(trimmed);
48
+ if (!Number.isFinite(n) || n < 0) return null;
49
+ return Math.round(n * 1e3);
50
+ }
51
+
52
+ // src/serverTracker.ts
53
+ function stampExposure(event) {
54
+ return event.eventId ? event : { ...event, eventId: randomUUID() };
55
+ }
56
+ function stampMetric(event) {
57
+ return event.eventId ? event : { ...event, eventId: randomUUID() };
58
+ }
23
59
  function createServerTracker(options) {
24
60
  const {
25
61
  sdkKey,
@@ -32,6 +68,7 @@ function createServerTracker(options) {
32
68
  } = options;
33
69
  const batchEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/batch");
34
70
  const metricEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/metric-batch");
71
+ const ordersEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/orders");
35
72
  let exposureQueue = [];
36
73
  let metricQueue = [];
37
74
  let flushTimer = null;
@@ -43,7 +80,7 @@ function createServerTracker(options) {
43
80
  }, flushInterval);
44
81
  function queue(event) {
45
82
  if (destroyed) return;
46
- exposureQueue.push(event);
83
+ exposureQueue.push(stampExposure(event));
47
84
  if (exposureQueue.length > maxQueueSize) {
48
85
  const dropped = exposureQueue.length - maxQueueSize;
49
86
  exposureQueue = exposureQueue.slice(-maxQueueSize);
@@ -57,7 +94,7 @@ function createServerTracker(options) {
57
94
  }
58
95
  function queueMetric(event) {
59
96
  if (destroyed) return;
60
- metricQueue.push(event);
97
+ metricQueue.push(stampMetric(event));
61
98
  if (metricQueue.length > maxQueueSize) {
62
99
  const dropped = metricQueue.length - maxQueueSize;
63
100
  metricQueue = metricQueue.slice(-maxQueueSize);
@@ -88,6 +125,8 @@ function createServerTracker(options) {
88
125
  }
89
126
  async function sendBatch(endpoint, key, payload) {
90
127
  const body = JSON.stringify({ sdkKey, [key]: payload });
128
+ let backoff = resetBackoff();
129
+ let pendingRetryAfterMs = void 0;
91
130
  for (let attempt = 0; attempt <= retryAttempts; attempt++) {
92
131
  try {
93
132
  const response = await fetch(endpoint, {
@@ -97,14 +136,17 @@ function createServerTracker(options) {
97
136
  signal: AbortSignal.timeout(1e4)
98
137
  });
99
138
  if (!response.ok) {
139
+ const parsed = parseRetryAfter(response.headers.get("retry-after"));
140
+ if (parsed !== null) pendingRetryAfterMs = parsed;
100
141
  throw new Error(`HTTP ${response.status}`);
101
142
  }
102
143
  logger.debug(`Flushed ${payload.length} events to ${endpoint}`);
103
144
  return;
104
145
  } catch (e) {
105
146
  if (attempt < retryAttempts) {
106
- const delay = 1e3 * Math.pow(2, attempt);
107
- await new Promise((r) => setTimeout(r, delay));
147
+ backoff = nextBackoff(backoff, pendingRetryAfterMs);
148
+ pendingRetryAfterMs = void 0;
149
+ await new Promise((r) => setTimeout(r, backoff.delayMs));
108
150
  } else {
109
151
  logger.error(
110
152
  `Failed to flush ${payload.length} events after ${retryAttempts} retries`,
@@ -114,6 +156,47 @@ function createServerTracker(options) {
114
156
  }
115
157
  }
116
158
  }
159
+ async function trackPurchase(visitorId, order) {
160
+ if (destroyed) return;
161
+ const payload = {
162
+ ...order,
163
+ eventId: randomUUID(),
164
+ visitorId,
165
+ timestamp: Date.now(),
166
+ pageUrl: ""
167
+ };
168
+ const body = JSON.stringify({ sdkKey, orders: [payload] });
169
+ let backoff = resetBackoff();
170
+ let pendingRetryAfterMs = void 0;
171
+ for (let attempt = 0; attempt <= retryAttempts; attempt++) {
172
+ try {
173
+ const response = await fetch(ordersEndpoint, {
174
+ method: "POST",
175
+ headers: { "Content-Type": "application/json" },
176
+ body,
177
+ signal: AbortSignal.timeout(1e4)
178
+ });
179
+ if (!response.ok) {
180
+ const parsed = parseRetryAfter(response.headers.get("retry-after"));
181
+ if (parsed !== null) pendingRetryAfterMs = parsed;
182
+ throw new Error(`HTTP ${response.status}`);
183
+ }
184
+ logger.debug(`Purchase order ${order.orderId} sent for visitor ${visitorId}`);
185
+ return;
186
+ } catch (e) {
187
+ if (attempt < retryAttempts) {
188
+ backoff = nextBackoff(backoff, pendingRetryAfterMs);
189
+ pendingRetryAfterMs = void 0;
190
+ await new Promise((r) => setTimeout(r, backoff.delayMs));
191
+ } else {
192
+ logger.error(
193
+ `Failed to send purchase order ${order.orderId} after ${retryAttempts} retries`,
194
+ { endpoint: ordersEndpoint, error: String(e) }
195
+ );
196
+ }
197
+ }
198
+ }
199
+ }
117
200
  async function destroy() {
118
201
  destroyed = true;
119
202
  if (flushTimer) {
@@ -122,20 +205,142 @@ function createServerTracker(options) {
122
205
  }
123
206
  await flush();
124
207
  }
125
- return { queue, queueMetric, flush, destroy };
208
+ return { queue, queueMetric, trackPurchase, flush, destroy };
126
209
  }
127
210
 
211
+ // src/streaming.ts
212
+ import {
213
+ createStreamingClient,
214
+ createBrowserStreamingClient
215
+ } from "@avsbhq/utils/streaming";
216
+
128
217
  // src/version.ts
129
218
  var SDK_VERSION = "0.3.0";
130
219
 
131
- // src/AvsBServer.ts
220
+ // src/UserBoundClient.ts
221
+ function createUserBoundClient(server, context) {
222
+ const decisionLog = createDecisionLog();
223
+ const forcedDecisions = /* @__PURE__ */ new Map();
224
+ function buildOptions(options) {
225
+ return {
226
+ context,
227
+ decideOptions: options?.decideOptions,
228
+ decisionLog
229
+ };
230
+ }
231
+ return {
232
+ getFlag(flagKey, defaultValue, options) {
233
+ const forced = forcedDecisions.get(flagKey);
234
+ if (forced) {
235
+ return server.getFlag(flagKey, defaultValue, {
236
+ ...buildOptions(options),
237
+ forcedVariationKey: forced.variationKey
238
+ });
239
+ }
240
+ return server.getFlag(flagKey, defaultValue, buildOptions(options));
241
+ },
242
+ getBoolFlag(flagKey, defaultValue, options) {
243
+ const forced = forcedDecisions.get(flagKey);
244
+ if (forced) {
245
+ return server.getBoolFlag(flagKey, defaultValue, {
246
+ ...buildOptions(options),
247
+ forcedVariationKey: forced.variationKey
248
+ });
249
+ }
250
+ return server.getBoolFlag(flagKey, defaultValue, buildOptions(options));
251
+ },
252
+ getStringFlag(flagKey, defaultValue, options) {
253
+ const forced = forcedDecisions.get(flagKey);
254
+ if (forced) {
255
+ return server.getStringFlag(flagKey, defaultValue, {
256
+ ...buildOptions(options),
257
+ forcedVariationKey: forced.variationKey
258
+ });
259
+ }
260
+ return server.getStringFlag(flagKey, defaultValue, buildOptions(options));
261
+ },
262
+ getNumberFlag(flagKey, defaultValue, options) {
263
+ const forced = forcedDecisions.get(flagKey);
264
+ if (forced) {
265
+ return server.getNumberFlag(flagKey, defaultValue, {
266
+ ...buildOptions(options),
267
+ forcedVariationKey: forced.variationKey
268
+ });
269
+ }
270
+ return server.getNumberFlag(flagKey, defaultValue, buildOptions(options));
271
+ },
272
+ getJsonFlag(flagKey, defaultValue, options) {
273
+ const forced = forcedDecisions.get(flagKey);
274
+ if (forced) {
275
+ return server.getJsonFlag(flagKey, defaultValue, {
276
+ ...buildOptions(options),
277
+ forcedVariationKey: forced.variationKey
278
+ });
279
+ }
280
+ return server.getJsonFlag(flagKey, defaultValue, buildOptions(options));
281
+ },
282
+ getAllFlags(options) {
283
+ return server.getAllFlags(context, options);
284
+ },
285
+ track(eventKey, payload) {
286
+ server.track(eventKey, { ...payload, context });
287
+ },
288
+ setForcedDecision(flagKey, opts) {
289
+ forcedDecisions.set(flagKey, opts);
290
+ },
291
+ clearForcedDecision(flagKey) {
292
+ forcedDecisions.delete(flagKey);
293
+ },
294
+ getDecisionLog() {
295
+ return decisionLog;
296
+ }
297
+ };
298
+ }
299
+
300
+ // src/utils.ts
301
+ import { createUtils } from "@avsbhq/utils";
302
+ import { notFoundFlag } from "@avsbhq/core";
303
+ function buildServerUtils(server) {
304
+ const adapter = {
305
+ getFlag(flagKey, defaultValue) {
306
+ return notFoundFlag(defaultValue, "no bound context");
307
+ },
308
+ getAllFlags(opts) {
309
+ const ctx = opts?.context;
310
+ if (!ctx) return {};
311
+ return server.getAllFlags(ctx, {
312
+ fireExposures: opts.fireExposures
313
+ });
314
+ },
315
+ track(eventKey, payload) {
316
+ if (!payload?.context) return;
317
+ server.track(eventKey, {
318
+ ...payload,
319
+ context: payload.context
320
+ });
321
+ },
322
+ on(event, cb) {
323
+ return server.on(event, cb);
324
+ },
325
+ getDatafile: () => server.getDatafile(),
326
+ getCurrentContext: () => null,
327
+ applyDatafile() {
328
+ },
329
+ applySnapshot() {
330
+ }
331
+ };
332
+ return createUtils(adapter);
333
+ }
334
+
335
+ // src/AvsbServer.ts
132
336
  var DEFAULT_EVENTS = {
133
337
  flushInterval: 2e3,
134
338
  batchSize: 10,
135
339
  maxQueueSize: 1e4,
136
340
  retryAttempts: 3
137
341
  };
138
- var AvsBServer = class {
342
+ var DEFAULT_STREAMING_ENDPOINT = "https://app.avsb.cloud/api/sdk/stream";
343
+ var AvsbServer = class {
139
344
  sdkKey;
140
345
  pollingInterval;
141
346
  cdnHost;
@@ -143,14 +348,49 @@ var AvsBServer = class {
143
348
  logger;
144
349
  stickyBucketService;
145
350
  eventOptions;
351
+ streamingEnabled;
352
+ streamingEndpoint;
146
353
  datafile;
147
354
  ready;
148
355
  destroyed;
149
356
  tracker;
357
+ streamingClient;
150
358
  pollingId;
359
+ abortController = new AbortController();
151
360
  overrides = /* @__PURE__ */ new Map();
152
361
  listeners = /* @__PURE__ */ new Map();
153
362
  readyPromise = null;
363
+ pendingEvents = [];
364
+ /**
365
+ * §6 — `client.utils.*` surface. Constructed lazily on first access so
366
+ * the utils package is only imported when actually consumed.
367
+ *
368
+ * The server's event surface (`ServerEventMap`) is a subset of the
369
+ * shared `ClientEventMap`; the utils-adapter `on` here ignores events
370
+ * the server doesn't emit (e.g. `flagChange`). Workstream 5 (CoW 6)
371
+ * harmonises the two maps when the streaming evaluator lands.
372
+ */
373
+ _utils = null;
374
+ get utils() {
375
+ if (!this._utils) {
376
+ this._utils = buildServerUtils({
377
+ getAllFlags: (ctx, opts) => this.getAllFlags(ctx, opts),
378
+ getFlag: (flagKey, defaultValue, opts) => this.getFlag(flagKey, defaultValue, opts),
379
+ track: (eventKey, payload) => this.track(eventKey, payload),
380
+ on: (event, cb) => {
381
+ if (event === "ready" || event === "configUpdate" || event === "evaluation" || event === "error" || event === "stickyOrphan") {
382
+ return this.on(
383
+ event,
384
+ cb
385
+ );
386
+ }
387
+ return () => void 0;
388
+ },
389
+ getDatafile: () => this.datafile
390
+ });
391
+ }
392
+ return this._utils;
393
+ }
154
394
  constructor(options) {
155
395
  this.sdkKey = options.sdkKey;
156
396
  this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
@@ -160,12 +400,18 @@ var AvsBServer = class {
160
400
  this.logger = options.logger ?? noopLogger;
161
401
  this.stickyBucketService = options.stickyBucketService ?? null;
162
402
  this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
403
+ this.streamingEnabled = options.streaming ?? false;
404
+ this.streamingEndpoint = options.streamingEndpoint ?? DEFAULT_STREAMING_ENDPOINT;
163
405
  this.datafile = options.bootstrap ?? null;
164
406
  this.ready = !!options.bootstrap;
165
407
  this.destroyed = false;
166
408
  this.tracker = null;
409
+ this.streamingClient = null;
167
410
  this.pollingId = null;
168
411
  }
412
+ // ---------------------------------------------------------------------------
413
+ // §5.4 onReady — never rejects, always resolves to InitResult
414
+ // ---------------------------------------------------------------------------
169
415
  onReady() {
170
416
  if (this.readyPromise) return this.readyPromise;
171
417
  this.readyPromise = this.initializeAndReady();
@@ -175,6 +421,7 @@ var AvsBServer = class {
175
421
  if (this.ready && this.datafile) {
176
422
  this.initTracker();
177
423
  this.startPolling();
424
+ this.startStreamingIfEnabled();
178
425
  this.sendHeartbeat();
179
426
  this.emit("ready");
180
427
  return { success: true, source: "bootstrap" };
@@ -188,6 +435,7 @@ var AvsBServer = class {
188
435
  ]);
189
436
  if (result.timedOut) {
190
437
  this.startPolling();
438
+ this.startStreamingIfEnabled();
191
439
  const error2 = new Error(`SDK initialization timed out after ${this.initTimeout}ms`);
192
440
  this.emit("error", error2);
193
441
  return { success: false, source: "timeout", error: error2 };
@@ -197,23 +445,34 @@ var AvsBServer = class {
197
445
  this.initTracker();
198
446
  this.ready = true;
199
447
  this.startPolling();
448
+ this.startStreamingIfEnabled();
200
449
  this.sendHeartbeat();
201
450
  this.emit("ready");
202
451
  return { success: true, source: "network" };
203
452
  }
204
453
  this.startPolling();
454
+ this.startStreamingIfEnabled();
205
455
  const error = new Error("Failed to fetch datafile");
206
456
  this.emit("error", error);
207
457
  return { success: false, source: "error", error };
208
458
  } catch (e) {
209
459
  this.startPolling();
460
+ this.startStreamingIfEnabled();
210
461
  const error = e instanceof Error ? e : new Error(String(e));
211
462
  this.emit("error", error);
212
463
  return { success: false, source: "error", error };
213
464
  }
214
465
  }
466
+ // ---------------------------------------------------------------------------
467
+ // Lifecycle
468
+ // ---------------------------------------------------------------------------
215
469
  async close() {
216
470
  this.destroyed = true;
471
+ this.abortController.abort();
472
+ if (this.streamingClient) {
473
+ this.streamingClient.disconnect();
474
+ this.streamingClient = null;
475
+ }
217
476
  if (this.pollingId) {
218
477
  clearInterval(this.pollingId);
219
478
  this.pollingId = null;
@@ -222,6 +481,7 @@ var AvsBServer = class {
222
481
  await this.tracker.destroy();
223
482
  this.tracker = null;
224
483
  }
484
+ this._utils?._drain();
225
485
  this.datafile = null;
226
486
  this.ready = false;
227
487
  }
@@ -245,125 +505,204 @@ var AvsBServer = class {
245
505
  this.logger.info("Datafile updated", { publishedAt: df.publishedAt });
246
506
  }
247
507
  }
248
- getFlag(flagKey, userContext, defaultValue) {
508
+ // ---------------------------------------------------------------------------
509
+ // §5.3 getFlag — required defaultValue, returns Flag<T>
510
+ // ---------------------------------------------------------------------------
511
+ getFlag(flagKey, defaultValue, options) {
512
+ const context = options?.context;
513
+ const decisionLog = options?.decisionLog;
514
+ if (!context) {
515
+ this.logger.warn(
516
+ `getFlag called without context for flagKey="${flagKey}" \u2014 returning default`
517
+ );
518
+ return notFoundFlag2(defaultValue, "no context provided");
519
+ }
249
520
  if (!this.ready || !this.datafile) {
250
521
  this.logger.debug(`SDK not ready, returning default for ${flagKey}`);
251
- return defaultValue;
522
+ return notFoundFlag2(defaultValue, "SDK not ready");
252
523
  }
253
- const result = this.evaluate(flagKey, userContext, defaultValue);
254
- this.emitEvaluation(flagKey, result.value, userContext.userId, result.source);
255
- return result.value ?? defaultValue;
256
- }
257
- getFlagDetails(flagKey, userContext) {
258
- if (!this.ready || !this.datafile) {
259
- return {
260
- value: void 0,
261
- variationKey: null,
262
- source: "not_found",
263
- ruleId: null,
264
- ruleType: null,
524
+ if (options?.forcedVariationKey) {
525
+ return this.buildFlagFromVariationKey(
265
526
  flagKey,
266
- reasons: ["SDK not ready"]
267
- };
527
+ options.forcedVariationKey,
528
+ defaultValue,
529
+ "runtimeOverride",
530
+ context,
531
+ decisionLog
532
+ );
268
533
  }
269
- const r = this.evaluate(flagKey, userContext, void 0);
270
- return {
271
- value: r.value,
272
- variationKey: r.variationKey,
273
- source: r.source,
274
- ruleId: r.ruleId,
275
- ruleType: r.ruleType,
276
- flagKey,
277
- reasons: r.reasons
278
- };
534
+ const legacyCtx = this.buildLegacyContext(context, options);
535
+ const result = this.evaluate(flagKey, legacyCtx, defaultValue);
536
+ this.emitEvaluation(flagKey, result.value, bucketingKey(context), result.source);
537
+ return this.toFlag(result, defaultValue, flagKey, context, decisionLog);
538
+ }
539
+ // ---------------------------------------------------------------------------
540
+ // §5.11 Typed evaluator variants
541
+ // ---------------------------------------------------------------------------
542
+ getBoolFlag(flagKey, defaultValue, options) {
543
+ return this.getTypedFlag(flagKey, defaultValue, "boolean", options);
279
544
  }
280
- getAllFlags(userContext) {
545
+ getStringFlag(flagKey, defaultValue, options) {
546
+ return this.getTypedFlag(flagKey, defaultValue, "string", options);
547
+ }
548
+ getNumberFlag(flagKey, defaultValue, options) {
549
+ return this.getTypedFlag(flagKey, defaultValue, "number", options);
550
+ }
551
+ getJsonFlag(flagKey, defaultValue, options) {
552
+ return this.getTypedFlag(flagKey, defaultValue, "json", options);
553
+ }
554
+ // ---------------------------------------------------------------------------
555
+ // §5.7 getAllFlags — returns Record<string, Flag>
556
+ // ---------------------------------------------------------------------------
557
+ getAllFlags(context, options) {
281
558
  if (!this.ready || !this.datafile) return {};
559
+ const fireExposures = options?.fireExposures === true;
282
560
  const out = {};
283
- for (const flag of this.datafile.flags) out[flag.key] = this.getFlag(flag.key, userContext);
561
+ for (const flagEntry of this.datafile.flags) {
562
+ const flagOptions = {
563
+ context,
564
+ decideOptions: options?.decideOptions
565
+ };
566
+ if (!fireExposures) {
567
+ const suppressCtx = this.buildLegacyContext(context, flagOptions);
568
+ suppressCtx.suppressExposure = true;
569
+ const result = this.evaluate(flagEntry.key, suppressCtx, void 0);
570
+ out[flagEntry.key] = this.toFlagNoLog(result, void 0, flagEntry.key);
571
+ } else {
572
+ out[flagEntry.key] = this.getFlag(flagEntry.key, void 0, flagOptions);
573
+ }
574
+ }
284
575
  return out;
285
576
  }
286
- track(eventKey, context) {
287
- if (!this.ready || !this.tracker) {
288
- this.logger.warn(`SDK not ready, dropping track event "${eventKey}"`);
577
+ // ---------------------------------------------------------------------------
578
+ // Purchase tracking — fires immediately to /v1/collect/orders
579
+ // ---------------------------------------------------------------------------
580
+ async trackPurchase(visitorId, order) {
581
+ if (!this.tracker) {
582
+ this.logger.warn("trackPurchase called before tracker is ready; order dropped");
583
+ return;
584
+ }
585
+ return this.tracker.trackPurchase(visitorId, order);
586
+ }
587
+ // ---------------------------------------------------------------------------
588
+ // §5.6 Unified track — context required
589
+ // ---------------------------------------------------------------------------
590
+ track(eventKey, payload) {
591
+ const context = payload.context;
592
+ const userId = bucketingKey(context);
593
+ if (!this.tracker) {
594
+ this.pendingEvents.push({ eventKey, payload });
289
595
  return;
290
596
  }
291
597
  this.tracker.queueMetric({
292
- visitorId: context.userId,
598
+ visitorId: userId,
293
599
  eventName: eventKey,
294
- revenue: context.revenue ?? null,
600
+ revenue: payload.value ?? null,
295
601
  timestamp: Date.now(),
296
- pageUrl: ""
602
+ pageUrl: "",
603
+ ...payload.properties ? { properties: payload.properties } : {}
297
604
  });
298
605
  }
299
- logExposure(flagKey, userContext) {
606
+ // ---------------------------------------------------------------------------
607
+ // §5.13 manualExposure — updated to EvalContext
608
+ // ---------------------------------------------------------------------------
609
+ manualExposure(flagKey, context) {
300
610
  if (!this.ready || !this.datafile || !this.tracker) return;
301
- const ctx = { attributes: { userId: userContext.userId } };
302
- const result = evaluateFlag(flagKey, void 0, ctx, this.datafile);
303
- if (result.source !== "rule" || !result.ruleId) return;
611
+ const legacyCtx = this.buildLegacyContext(context, { suppressExposure: true });
612
+ const result = this.evaluate(flagKey, legacyCtx, void 0);
613
+ if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
614
+ if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
304
615
  const flag = this.datafile.flags.find((f) => f.key === flagKey);
305
616
  if (!flag) return;
306
- const rule = flag.rules.find((r) => r.id === result.ruleId);
307
- if (!rule || rule.type !== "ab_test") return;
308
- const rv = rule.variations.find((v) => {
309
- const fv = flag.variations.find((x) => x.id === v.variationId);
310
- return fv?.key === result.variationKey;
311
- });
312
- if (!rv) return;
617
+ const variation = flag.variations.find((v) => v.key === result.variationKey);
618
+ if (!variation) return;
619
+ const userId = bucketingKey(context);
313
620
  this.tracker.queue({
314
- visitorId: userContext.userId,
621
+ visitorId: userId,
315
622
  experimentId: result.ruleId,
316
- variationId: rv.variationId,
623
+ variationId: variation.id,
317
624
  eventName: "flag_exposure",
318
625
  revenue: null,
319
626
  timestamp: Date.now(),
320
627
  pageUrl: "",
321
- properties: { flagKey, flagId: flag.id }
628
+ properties: { flagKey, flagId: flag.id, manuallyFired: true }
322
629
  });
323
630
  }
324
- setOverride(flagKey, variationKey, userId) {
325
- if (!this.overrides.has(flagKey)) this.overrides.set(flagKey, /* @__PURE__ */ new Map());
326
- this.overrides.get(flagKey).set(userId ?? "*", variationKey);
631
+ // ---------------------------------------------------------------------------
632
+ // §5.8 forUser / createUserContext — UserBoundClient factory
633
+ // ---------------------------------------------------------------------------
634
+ forUser(context) {
635
+ return createUserBoundClient(this, context);
636
+ }
637
+ createUserContext(context) {
638
+ return createUserBoundClient(this, context);
639
+ }
640
+ // ---------------------------------------------------------------------------
641
+ // §5.9 Override methods — restructured storage
642
+ // ---------------------------------------------------------------------------
643
+ setOverrideForUser(flagKey, contextKey, variationKey, contextKind = "user") {
644
+ if (!this.overrides.has(flagKey)) {
645
+ this.overrides.set(flagKey, { perKindKey: /* @__PURE__ */ new Map() });
646
+ }
647
+ const entry = this.overrides.get(flagKey);
648
+ entry.perKindKey.set(`${contextKind}:${contextKey}`, variationKey);
649
+ }
650
+ setGlobalOverride(flagKey, variationKey) {
651
+ if (!this.overrides.has(flagKey)) {
652
+ this.overrides.set(flagKey, { perKindKey: /* @__PURE__ */ new Map() });
653
+ }
654
+ this.overrides.get(flagKey).global = variationKey;
655
+ }
656
+ clearOverrideForUser(flagKey, contextKey, contextKind = "user") {
657
+ const entry = this.overrides.get(flagKey);
658
+ if (!entry) return;
659
+ entry.perKindKey.delete(`${contextKind}:${contextKey}`);
660
+ if (!entry.global && entry.perKindKey.size === 0) {
661
+ this.overrides.delete(flagKey);
662
+ }
327
663
  }
328
- clearOverride(flagKey, userId) {
329
- const m = this.overrides.get(flagKey);
330
- if (!m) return;
331
- m.delete(userId ?? "*");
332
- if (m.size === 0) this.overrides.delete(flagKey);
664
+ clearGlobalOverride(flagKey) {
665
+ const entry = this.overrides.get(flagKey);
666
+ if (!entry) return;
667
+ entry.global = void 0;
668
+ if (entry.perKindKey.size === 0) {
669
+ this.overrides.delete(flagKey);
670
+ }
333
671
  }
334
672
  clearAllOverrides() {
335
673
  this.overrides.clear();
336
674
  }
675
+ // ---------------------------------------------------------------------------
676
+ // Decision log factory (convenience)
677
+ // ---------------------------------------------------------------------------
337
678
  createDecisionLog() {
338
679
  return createDecisionLog();
339
680
  }
681
+ // ---------------------------------------------------------------------------
682
+ // Event listener API — on() returns unsubscribe; off() kept for compat
683
+ // ---------------------------------------------------------------------------
340
684
  on(event, cb) {
341
685
  if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
342
686
  this.listeners.get(event).add(cb);
687
+ return () => {
688
+ this.listeners.get(event)?.delete(cb);
689
+ };
343
690
  }
344
691
  off(event, cb) {
345
692
  this.listeners.get(event)?.delete(cb);
346
693
  }
694
+ // ---------------------------------------------------------------------------
695
+ // Private — core evaluate
696
+ // ---------------------------------------------------------------------------
347
697
  evaluate(flagKey, uc, defaultValue) {
348
- const startTime = Date.now();
349
698
  const reasons = [];
350
699
  const rtOver = this.resolveRuntimeOverride(flagKey, uc.userId);
351
700
  if (rtOver !== void 0) {
352
701
  reasons.push(`matched runtime override for flagKey="${flagKey}"`);
353
- this.recordDecisionLog(uc, {
354
- flagKey,
355
- value: rtOver.value,
356
- variationKey: rtOver.key,
357
- source: "runtime_override",
358
- ruleId: null,
359
- ruleType: null,
360
- reasons,
361
- startTime
362
- });
363
702
  return {
364
703
  value: rtOver.value,
365
704
  variationKey: rtOver.key,
366
- source: "runtime_override",
705
+ source: "runtimeOverride",
367
706
  ruleId: null,
368
707
  ruleType: null,
369
708
  reasons
@@ -373,31 +712,21 @@ var AvsBServer = class {
373
712
  const sticky = this.resolveStickyBucket(flagKey, uc.userId);
374
713
  if (sticky !== void 0) {
375
714
  reasons.push(`matched sticky bucket assignment for userId="${uc.userId}"`);
376
- if (!uc.suppressExposure) this.fireExposureForVariation(flagKey, sticky, uc);
377
- this.recordDecisionLog(uc, {
378
- flagKey,
379
- value: sticky.value,
380
- variationKey: sticky.key,
381
- source: "sticky",
382
- ruleId: null,
383
- ruleType: null,
384
- reasons,
385
- startTime
386
- });
715
+ if (!uc.suppressExposure) {
716
+ this.fireExposureForStickyAssignment(flagKey, sticky, uc);
717
+ }
387
718
  return {
388
- value: sticky.value,
389
- variationKey: sticky.key,
719
+ value: sticky.vref.value,
720
+ variationKey: sticky.vref.key,
390
721
  source: "sticky",
391
- ruleId: null,
392
- ruleType: null,
722
+ ruleId: sticky.assignment.ruleId,
723
+ ruleType: sticky.assignment.ruleType,
393
724
  reasons
394
725
  };
395
726
  }
396
727
  }
397
- const context = { attributes: { userId: uc.userId, ...uc.attributes } };
398
- const shouldFire = !uc.suppressExposure && !uc.decisionLog?.entries.some(
399
- (e) => e.flagKey === flagKey && e.source !== "not_found" && e.source !== "disabled"
400
- );
728
+ const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
729
+ const shouldFire = !uc.suppressExposure;
401
730
  const onExposure = shouldFire ? (event) => {
402
731
  if (uc.privateAttributes?.length && event.properties) {
403
732
  const cleaned = { ...event.properties };
@@ -406,41 +735,216 @@ var AvsBServer = class {
406
735
  }
407
736
  this.tracker?.queue(event);
408
737
  } : void 0;
409
- const r = evaluateFlag(flagKey, defaultValue, context, this.datafile, onExposure, reasons);
410
- if (this.stickyBucketService && r.variationKey) {
738
+ const r = evaluateFlag(
739
+ flagKey,
740
+ defaultValue,
741
+ legacyCtx,
742
+ this.datafile,
743
+ onExposure,
744
+ reasons
745
+ );
746
+ if (this.stickyBucketService && r.variationKey && r.source === "rule" && r.ruleId && r.ruleType === "ab_test") {
411
747
  const v = this.findVariation(flagKey, r.variationKey);
412
- if (v) this.stickyBucketService.save(uc.userId, flagKey, v.id);
748
+ if (v) {
749
+ this.stickyBucketService.save(uc.userId, flagKey, {
750
+ variationId: v.id,
751
+ ruleId: r.ruleId,
752
+ ruleType: r.ruleType,
753
+ assignedAt: Date.now()
754
+ });
755
+ }
413
756
  }
414
- this.recordDecisionLog(uc, {
415
- flagKey,
416
- value: r.value,
417
- variationKey: r.variationKey,
418
- source: r.source,
419
- ruleId: r.ruleId,
420
- ruleType: r.ruleType,
421
- reasons,
422
- startTime
423
- });
424
757
  return {
425
758
  value: r.value,
426
759
  variationKey: r.variationKey,
427
- source: r.source,
760
+ source: mapLegacySource(r.source),
428
761
  ruleId: r.ruleId,
429
762
  ruleType: r.ruleType,
430
763
  reasons
431
764
  };
432
765
  }
766
+ // ---------------------------------------------------------------------------
767
+ // Private — typed evaluation helper
768
+ // ---------------------------------------------------------------------------
769
+ getTypedFlag(flagKey, defaultValue, expectedType, options) {
770
+ const flag = this.getFlag(flagKey, defaultValue, options);
771
+ if (flag.source === "not_found" || flag.source === "disabled") {
772
+ return flag;
773
+ }
774
+ const datafileFlag = findDatafileFlag(this.datafile, flagKey);
775
+ const mismatch = checkExpectedType(datafileFlag, expectedType, flag.value);
776
+ if (mismatch) {
777
+ this.logger.warn(
778
+ `Type mismatch on flag "${flagKey}": ${mismatch} \u2014 returning default`
779
+ );
780
+ return notFoundFlag2(defaultValue, mismatch);
781
+ }
782
+ return flag;
783
+ }
784
+ // ---------------------------------------------------------------------------
785
+ // Private — build Flag<T> from an EvalResult
786
+ // ---------------------------------------------------------------------------
787
+ toFlag(result, defaultValue, flagKey, context, decisionLog, startMicros) {
788
+ const evaluatedAt = Date.now();
789
+ const durationMicros = startMicros !== void 0 ? performance.now() * 1e3 - startMicros : 0;
790
+ const flag = createFlag({
791
+ value: result.value ?? defaultValue,
792
+ variationKey: result.variationKey,
793
+ source: result.source,
794
+ ruleId: result.ruleId,
795
+ ruleType: result.ruleType,
796
+ reasons: result.reasons,
797
+ evaluatedAt,
798
+ durationMicros: Math.round(durationMicros)
799
+ });
800
+ if (decisionLog) {
801
+ const privateAttrs = privateAttributesFrom(context);
802
+ const summary = contextSummary(context);
803
+ const redactedValue = privateAttrs.size > 0 && typeof result.value === "string" && privateAttrs.has(result.value) ? "[REDACTED]" : result.value;
804
+ decisionLog.entries.push({
805
+ flagKey,
806
+ value: redactedValue,
807
+ variationKey: result.variationKey,
808
+ source: result.source,
809
+ ruleId: result.ruleId,
810
+ ruleType: result.ruleType,
811
+ reasons: [...result.reasons],
812
+ evaluatedAt,
813
+ durationMicros: Math.round(durationMicros),
814
+ contextSummary: summary
815
+ });
816
+ }
817
+ return flag;
818
+ }
819
+ /** toFlag variant that never writes a decision log (used by getAllFlags suppressed path). */
820
+ toFlagNoLog(result, defaultValue, _flagKey) {
821
+ return createFlag({
822
+ value: result.value ?? defaultValue,
823
+ variationKey: result.variationKey,
824
+ source: result.source,
825
+ ruleId: result.ruleId,
826
+ ruleType: result.ruleType,
827
+ reasons: result.reasons,
828
+ evaluatedAt: Date.now(),
829
+ durationMicros: 0
830
+ });
831
+ }
832
+ /** Build a Flag<T> from a forced variation key (per-request override). */
833
+ buildFlagFromVariationKey(flagKey, variationKey, defaultValue, source, context, decisionLog) {
834
+ const vref = this.findVariation(flagKey, variationKey);
835
+ const value = vref ? vref.value : defaultValue;
836
+ const result = {
837
+ value,
838
+ variationKey: vref ? vref.key : null,
839
+ source,
840
+ ruleId: null,
841
+ ruleType: null,
842
+ reasons: [`forced decision: variationKey="${variationKey}"`]
843
+ };
844
+ return this.toFlag(result, defaultValue, flagKey, context, decisionLog);
845
+ }
846
+ // ---------------------------------------------------------------------------
847
+ // Private — build LegacyUserContext from EvalContext
848
+ // ---------------------------------------------------------------------------
849
+ buildLegacyContext(context, options) {
850
+ const legacy = toLegacyEvalContext(context);
851
+ const userId = legacy.attributes["userId"] ?? "";
852
+ const privateAttrs = privateAttributesFrom(context);
853
+ const suppressExposure = options?.suppressExposure === true || options?.decideOptions?.includes("DISABLE_EXPOSURE") === true;
854
+ const { userId: _uid, ...rest } = legacy.attributes;
855
+ return {
856
+ userId,
857
+ attributes: rest,
858
+ suppressExposure,
859
+ privateAttributes: privateAttrs.size > 0 ? [...privateAttrs] : void 0
860
+ };
861
+ }
862
+ // ---------------------------------------------------------------------------
863
+ // Private — override resolution
864
+ // ---------------------------------------------------------------------------
865
+ resolveRuntimeOverride(flagKey, userId) {
866
+ const entry = this.overrides.get(flagKey);
867
+ if (!entry) return void 0;
868
+ const userVk = entry.perKindKey.get(`user:${userId}`);
869
+ const vk = userVk ?? entry.global;
870
+ if (!vk) return void 0;
871
+ return this.findVariation(flagKey, vk) ?? void 0;
872
+ }
873
+ // ---------------------------------------------------------------------------
874
+ // Private — sticky bucket helpers
875
+ // ---------------------------------------------------------------------------
876
+ resolveStickyBucket(flagKey, userId) {
877
+ if (!this.stickyBucketService || !this.datafile) return void 0;
878
+ const assignment = this.stickyBucketService.lookup(userId, flagKey);
879
+ if (!assignment) return void 0;
880
+ const flag = this.datafile.flags.find((f) => f.key === flagKey);
881
+ if (!flag) return void 0;
882
+ const v = flag.variations.find((x) => x.id === assignment.variationId);
883
+ if (!v) return void 0;
884
+ return { vref: { value: v.value, key: v.key, id: v.id }, assignment };
885
+ }
886
+ findVariation(flagKey, variationKey) {
887
+ if (!this.datafile) return null;
888
+ const flag = this.datafile.flags.find((f) => f.key === flagKey);
889
+ if (!flag) return null;
890
+ const v = flag.variations.find((x) => x.key === variationKey);
891
+ if (!v) return null;
892
+ return { value: v.value, key: v.key, id: v.id };
893
+ }
894
+ fireExposureForStickyAssignment(flagKey, sticky, uc) {
895
+ if (!this.tracker || !this.datafile) return;
896
+ const flag = this.datafile.flags.find((f) => f.key === flagKey);
897
+ if (!flag) return;
898
+ const rule = flag.rules.find((r) => r.id === sticky.assignment.ruleId);
899
+ if (!rule || !rule.variations.some((rv) => rv.variationId === sticky.vref.id)) {
900
+ this.logger.warn(
901
+ "Sticky assignment references a rule no longer matching the datafile",
902
+ {
903
+ flagKey,
904
+ userId: uc.userId,
905
+ assignedRuleId: sticky.assignment.ruleId,
906
+ assignedVariationId: sticky.vref.id
907
+ }
908
+ );
909
+ this.emit("stickyOrphan", {
910
+ flagKey,
911
+ userId: uc.userId,
912
+ assignment: sticky.assignment
913
+ });
914
+ return;
915
+ }
916
+ this.tracker.queue({
917
+ visitorId: uc.userId,
918
+ experimentId: rule.id,
919
+ variationId: sticky.vref.id,
920
+ eventName: "flag_exposure",
921
+ revenue: null,
922
+ timestamp: Date.now(),
923
+ pageUrl: "",
924
+ properties: { flagKey, flagId: flag.id }
925
+ });
926
+ }
927
+ // ---------------------------------------------------------------------------
928
+ // Private — event emission
929
+ // ---------------------------------------------------------------------------
433
930
  emit(event, ...args) {
434
931
  const cbs = this.listeners.get(event);
435
932
  if (!cbs) return;
436
933
  for (const cb of cbs) {
437
934
  try {
935
+ ;
438
936
  cb(...args);
439
937
  } catch (e) {
440
938
  this.logger.error("Listener error", { event, error: String(e) });
441
939
  }
442
940
  }
443
941
  }
942
+ emitEvaluation(flagKey, value, userId, source) {
943
+ this.emit("evaluation", { flagKey, value, userId, source });
944
+ }
945
+ // ---------------------------------------------------------------------------
946
+ // Private — tracker + polling + streaming
947
+ // ---------------------------------------------------------------------------
444
948
  initTracker() {
445
949
  if (!this.datafile || this.tracker) return;
446
950
  this.tracker = createServerTracker({
@@ -452,17 +956,37 @@ var AvsBServer = class {
452
956
  retryAttempts: this.eventOptions.retryAttempts,
453
957
  logger: this.logger
454
958
  });
959
+ if (this.pendingEvents.length > 0) {
960
+ const queued = this.pendingEvents;
961
+ this.pendingEvents = [];
962
+ for (const item of queued) {
963
+ this.track(item.eventKey, item.payload);
964
+ }
965
+ }
966
+ }
967
+ startStreamingIfEnabled() {
968
+ if (!this.streamingEnabled || this.streamingClient || this.destroyed) return;
969
+ this.streamingClient = createStreamingClient({
970
+ endpoint: this.streamingEndpoint,
971
+ sdkKey: this.sdkKey,
972
+ onDatafileUpdated: () => {
973
+ void this.refreshDatafile();
974
+ },
975
+ logger: this.logger
976
+ });
977
+ this.streamingClient.connect();
455
978
  }
456
979
  async fetchDatafile() {
457
980
  const url = `${this.cdnHost}/${this.sdkKey}/datafile.json`;
458
981
  try {
459
- const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
982
+ const res = await fetch(url, { signal: this.abortController.signal });
460
983
  if (!res.ok) {
461
984
  this.logger.warn("Datafile fetch failed", { status: res.status, url });
462
985
  return null;
463
986
  }
464
987
  return await res.json();
465
988
  } catch (e) {
989
+ if (this.abortController.signal.aborted) return null;
466
990
  this.logger.warn("Datafile fetch error", { error: String(e), url });
467
991
  return null;
468
992
  }
@@ -490,96 +1014,58 @@ var AvsBServer = class {
490
1014
  fetch(this.datafile.heartbeatEndpoint, {
491
1015
  method: "POST",
492
1016
  headers: { "Content-Type": "application/json" },
493
- body: JSON.stringify({ sdkKey: this.sdkKey, sdkType: "@avsbhq/node", sdkVersion: SDK_VERSION })
1017
+ body: JSON.stringify({
1018
+ sdkKey: this.sdkKey,
1019
+ sdkType: "@avsbhq/node",
1020
+ sdkVersion: SDK_VERSION
1021
+ }),
1022
+ signal: this.abortController.signal
494
1023
  }).catch(() => {
495
1024
  });
496
1025
  }
497
- resolveRuntimeOverride(flagKey, userId) {
498
- const m = this.overrides.get(flagKey);
499
- if (!m) return void 0;
500
- const vk = m.get(userId) ?? m.get("*");
501
- if (!vk) return void 0;
502
- return this.findVariation(flagKey, vk) ?? void 0;
503
- }
504
- resolveStickyBucket(flagKey, userId) {
505
- if (!this.stickyBucketService || !this.datafile) return void 0;
506
- const vid = this.stickyBucketService.lookup(userId, flagKey);
507
- if (!vid) return void 0;
508
- const flag = this.datafile.flags.find((f) => f.key === flagKey);
509
- if (!flag) return void 0;
510
- const v = flag.variations.find((x) => x.id === vid);
511
- if (!v) return void 0;
512
- return { value: v.value, key: v.key, id: v.id };
513
- }
514
- findVariation(flagKey, variationKey) {
515
- if (!this.datafile) return null;
516
- const flag = this.datafile.flags.find((f) => f.key === flagKey);
517
- if (!flag) return null;
518
- const v = flag.variations.find((x) => x.key === variationKey);
519
- if (!v) return null;
520
- return { value: v.value, key: v.key, id: v.id };
521
- }
522
- fireExposureForVariation(flagKey, vr, uc) {
523
- if (!this.tracker || !this.datafile) return;
524
- const flag = this.datafile.flags.find((f) => f.key === flagKey);
525
- if (!flag) return;
526
- for (const rule of flag.rules) {
527
- if (rule.type !== "ab_test") continue;
528
- if (rule.variations.some((rv) => rv.variationId === vr.id)) {
529
- this.tracker.queue({
530
- visitorId: uc.userId,
531
- experimentId: rule.id,
532
- variationId: vr.id,
533
- eventName: "flag_exposure",
534
- revenue: null,
535
- timestamp: Date.now(),
536
- pageUrl: "",
537
- properties: { flagKey, flagId: flag.id }
538
- });
539
- return;
540
- }
541
- }
542
- }
543
- recordDecisionLog(uc, d) {
544
- if (!uc.decisionLog) return;
545
- uc.decisionLog.entries.push({
546
- flagKey: d.flagKey,
547
- value: d.value,
548
- variationKey: d.variationKey,
549
- source: d.source,
550
- ruleId: d.ruleId,
551
- ruleType: d.ruleType,
552
- reasons: [...d.reasons],
553
- evaluatedAt: d.startTime,
554
- durationMs: Date.now() - d.startTime
555
- });
556
- }
557
- emitEvaluation(flagKey, value, userId, source) {
558
- this.emit("evaluation", { flagKey, value, userId, source });
559
- }
560
1026
  };
561
1027
 
562
1028
  // src/middleware.ts
563
- function createNoopRequestAvsB() {
1029
+ function createNoopUserBoundClient() {
1030
+ const noopFlag = (defaultValue) => ({
1031
+ value: defaultValue,
1032
+ variationKey: null,
1033
+ source: "not_found",
1034
+ ruleId: null,
1035
+ ruleType: null,
1036
+ reasons: ["no userId available"],
1037
+ evaluatedAt: Date.now(),
1038
+ durationMicros: 0,
1039
+ isEnabled: () => false,
1040
+ exists: () => false
1041
+ });
564
1042
  return {
565
1043
  getFlag(_flagKey, defaultValue) {
566
- return defaultValue;
1044
+ return noopFlag(defaultValue);
567
1045
  },
568
- getFlagDetails(flagKey) {
569
- return {
570
- value: void 0,
571
- variationKey: null,
572
- source: "not_found",
573
- ruleId: null,
574
- ruleType: null,
575
- flagKey,
576
- reasons: ["no userId available"]
577
- };
1046
+ getBoolFlag(_flagKey, defaultValue) {
1047
+ return noopFlag(defaultValue);
1048
+ },
1049
+ getStringFlag(_flagKey, defaultValue) {
1050
+ return noopFlag(defaultValue);
1051
+ },
1052
+ getNumberFlag(_flagKey, defaultValue) {
1053
+ return noopFlag(defaultValue);
1054
+ },
1055
+ getJsonFlag(_flagKey, defaultValue) {
1056
+ return noopFlag(defaultValue);
578
1057
  },
579
1058
  getAllFlags() {
580
1059
  return {};
581
1060
  },
582
1061
  track() {
1062
+ },
1063
+ setForcedDecision() {
1064
+ },
1065
+ clearForcedDecision() {
1066
+ },
1067
+ getDecisionLog() {
1068
+ return { entries: [] };
583
1069
  }
584
1070
  };
585
1071
  }
@@ -588,25 +1074,12 @@ function expressMiddleware(client, config) {
588
1074
  const userId = config.userIdFrom(req);
589
1075
  if (!userId) {
590
1076
  ;
591
- req.avsb = createNoopRequestAvsB();
1077
+ req.avsb = createNoopUserBoundClient();
592
1078
  return next();
593
1079
  }
594
1080
  const attributes = config.attributesFrom?.(req) ?? {};
595
- const baseContext = { userId, attributes };
596
- req.avsb = {
597
- getFlag(flagKey, defaultValue) {
598
- return client.getFlag(flagKey, baseContext, defaultValue);
599
- },
600
- getFlagDetails(flagKey) {
601
- return client.getFlagDetails(flagKey, baseContext);
602
- },
603
- getAllFlags() {
604
- return client.getAllFlags(baseContext);
605
- },
606
- track(eventKey, properties) {
607
- client.track(eventKey, { userId, revenue: properties?.revenue, properties });
608
- }
609
- };
1081
+ const context = { kind: "user", key: userId, ...attributes };
1082
+ req.avsb = client.forUser(context);
610
1083
  next();
611
1084
  };
612
1085
  }
@@ -619,30 +1092,20 @@ function fastifyPlugin(instance, opts, done) {
619
1092
  const userId = opts.userIdFrom(req);
620
1093
  if (!userId) {
621
1094
  ;
622
- req.avsb = createNoopRequestAvsB();
1095
+ req.avsb = createNoopUserBoundClient();
623
1096
  return hookDone();
624
1097
  }
625
1098
  const attributes = opts.attributesFrom?.(req) ?? {};
626
- const baseContext = { userId, attributes };
627
- req.avsb = {
628
- getFlag(flagKey, defaultValue) {
629
- return opts.client.getFlag(flagKey, baseContext, defaultValue);
630
- },
631
- getFlagDetails(flagKey) {
632
- return opts.client.getFlagDetails(flagKey, baseContext);
633
- },
634
- getAllFlags() {
635
- return opts.client.getAllFlags(baseContext);
636
- },
637
- track(eventKey, properties) {
638
- opts.client.track(eventKey, { userId, revenue: properties?.revenue, properties });
639
- }
640
- };
1099
+ const context = { kind: "user", key: userId, ...attributes };
1100
+ req.avsb = opts.client.forUser(context);
641
1101
  hookDone();
642
1102
  });
643
1103
  done();
644
1104
  }
645
1105
 
1106
+ // src/logger.ts
1107
+ import { noopLogger as noopLogger2, createLogger, consoleLogger, consoleTransport } from "@avsbhq/core";
1108
+
646
1109
  // src/stickyBucket.ts
647
1110
  var InMemoryStickyBucketService = class {
648
1111
  cache = /* @__PURE__ */ new Map();
@@ -652,10 +1115,15 @@ var InMemoryStickyBucketService = class {
652
1115
  lookup(userId, flagKey) {
653
1116
  return this.cache.get(this.key(userId, flagKey)) ?? null;
654
1117
  }
655
- save(userId, flagKey, variationId) {
656
- this.cache.set(this.key(userId, flagKey), variationId);
1118
+ save(userId, flagKey, assignment) {
1119
+ this.cache.set(this.key(userId, flagKey), assignment);
657
1120
  }
658
1121
  };
1122
+ function isStickyAssignment(value) {
1123
+ if (!value || typeof value !== "object") return false;
1124
+ const v = value;
1125
+ return typeof v.variationId === "string" && typeof v.ruleId === "string" && typeof v.ruleType === "string" && typeof v.assignedAt === "number";
1126
+ }
659
1127
  var RedisStickyBucketService = class {
660
1128
  redis;
661
1129
  prefix;
@@ -668,7 +1136,7 @@ var RedisStickyBucketService = class {
668
1136
  this.prefix = options.prefix ?? "avsb:sticky:";
669
1137
  this.ttl = options.ttl ?? 2592e3;
670
1138
  this.maxCacheSize = options.cacheSize ?? 1e4;
671
- this.logger = options.logger ?? noopLogger;
1139
+ this.logger = options.logger ?? noopLogger2;
672
1140
  }
673
1141
  redisKey(userId, flagKey) {
674
1142
  return `${this.prefix}${userId}:${flagKey}`;
@@ -683,12 +1151,13 @@ var RedisStickyBucketService = class {
683
1151
  this.backgroundFetch(userId, flagKey);
684
1152
  return null;
685
1153
  }
686
- save(userId, flagKey, variationId) {
1154
+ save(userId, flagKey, assignment) {
687
1155
  const ck = this.cacheKey(userId, flagKey);
688
- this.cache.set(ck, variationId);
1156
+ this.cache.set(ck, assignment);
689
1157
  this.evictIfNeeded();
690
1158
  const rk = this.redisKey(userId, flagKey);
691
- this.redis.set(rk, variationId, { EX: this.ttl }).catch((e) => {
1159
+ const serialized = JSON.stringify(assignment);
1160
+ this.redis.set(rk, serialized, { EX: this.ttl }).catch((e) => {
692
1161
  this.logger.error("Redis sticky bucket save failed", {
693
1162
  key: rk,
694
1163
  error: String(e)
@@ -699,10 +1168,20 @@ var RedisStickyBucketService = class {
699
1168
  const rk = this.redisKey(userId, flagKey);
700
1169
  const ck = this.cacheKey(userId, flagKey);
701
1170
  this.redis.get(rk).then((value) => {
702
- if (value !== null) {
703
- this.cache.set(ck, value);
704
- this.evictIfNeeded();
1171
+ if (value === null) return;
1172
+ let parsed;
1173
+ try {
1174
+ parsed = JSON.parse(value);
1175
+ } catch {
1176
+ this.logger.warn("Redis sticky bucket value is not valid JSON", { key: rk });
1177
+ return;
1178
+ }
1179
+ if (!isStickyAssignment(parsed)) {
1180
+ this.logger.warn("Redis sticky bucket value missing required fields", { key: rk });
1181
+ return;
705
1182
  }
1183
+ this.cache.set(ck, parsed);
1184
+ this.evictIfNeeded();
706
1185
  }).catch((e) => {
707
1186
  this.logger.error("Redis sticky bucket fetch failed", {
708
1187
  key: rk,
@@ -719,112 +1198,20 @@ var RedisStickyBucketService = class {
719
1198
  }
720
1199
  };
721
1200
 
722
- // src/streaming.ts
723
- function createStreamingClient(options) {
724
- const {
725
- endpoint,
726
- sdkKey,
727
- onDatafileUpdated,
728
- logger,
729
- reconnectDelay = 5e3
730
- } = options;
731
- let connected = false;
732
- let abortController = null;
733
- let reconnectTimer = null;
734
- function connect() {
735
- if (connected) return;
736
- startConnection();
737
- }
738
- function disconnect() {
739
- connected = false;
740
- if (abortController) {
741
- abortController.abort();
742
- abortController = null;
743
- }
744
- if (reconnectTimer) {
745
- clearTimeout(reconnectTimer);
746
- reconnectTimer = null;
747
- }
748
- }
749
- function isConnected() {
750
- return connected;
751
- }
752
- async function startConnection() {
753
- abortController = new AbortController();
754
- const url = `${endpoint}?sdkKey=${encodeURIComponent(sdkKey)}`;
755
- try {
756
- const response = await fetch(url, {
757
- headers: { Accept: "text/event-stream" },
758
- signal: abortController.signal
759
- });
760
- if (!response.ok) {
761
- logger.warn("SSE connection failed", { status: response.status, url });
762
- scheduleReconnect();
763
- return;
764
- }
765
- if (!response.body) {
766
- logger.warn("SSE response has no body");
767
- scheduleReconnect();
768
- return;
769
- }
770
- connected = true;
771
- logger.info(`SSE connected to ${endpoint}`);
772
- const reader = response.body.getReader();
773
- const decoder = new TextDecoder();
774
- let buffer = "";
775
- try {
776
- while (true) {
777
- const { done, value } = await reader.read();
778
- if (done) break;
779
- buffer += decoder.decode(value, { stream: true });
780
- const lines = buffer.split("\n");
781
- buffer = lines.pop() ?? "";
782
- for (const line of lines) {
783
- if (line.startsWith("data: ")) {
784
- const data = line.slice(6).trim();
785
- if (!data) continue;
786
- try {
787
- const parsed = JSON.parse(data);
788
- if (parsed.type === "datafile_updated") {
789
- onDatafileUpdated();
790
- }
791
- } catch {
792
- logger.warn("Failed to parse SSE data", { data });
793
- }
794
- }
795
- }
796
- }
797
- } catch (e) {
798
- if (abortController?.signal.aborted) return;
799
- logger.warn("SSE stream read error", { error: String(e) });
800
- }
801
- connected = false;
802
- logger.warn(`SSE disconnected, reconnecting in ${reconnectDelay}ms`);
803
- scheduleReconnect();
804
- } catch (e) {
805
- if (abortController?.signal.aborted) return;
806
- logger.warn("SSE connection error", { error: String(e) });
807
- connected = false;
808
- scheduleReconnect();
809
- }
810
- }
811
- function scheduleReconnect() {
812
- if (reconnectTimer) return;
813
- reconnectTimer = setTimeout(() => {
814
- reconnectTimer = null;
815
- startConnection();
816
- }, reconnectDelay);
817
- }
818
- return { connect, disconnect, isConnected };
819
- }
1201
+ // src/index.ts
1202
+ import { DecideOption } from "@avsbhq/core";
820
1203
  export {
821
- AvsBServer,
1204
+ AvsbServer,
1205
+ DecideOption,
822
1206
  InMemoryStickyBucketService,
823
1207
  RedisStickyBucketService,
824
1208
  SDK_VERSION,
1209
+ consoleLogger,
1210
+ consoleTransport,
825
1211
  createDecisionLog,
1212
+ createLogger,
826
1213
  createStreamingClient,
827
1214
  expressMiddleware,
828
1215
  fastifyPlugin,
829
- noopLogger
1216
+ noopLogger2 as noopLogger
830
1217
  };