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