@avsbhq/node 0.3.0 → 1.0.0

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