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