@avsbhq/node 1.7.2 → 1.9.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/README.md +34 -23
- package/dist/index.cjs +913 -881
- package/dist/index.d.cts +9 -107
- package/dist/index.d.ts +9 -107
- package/dist/index.js +905 -872
- package/package.json +4 -3
package/dist/index.cjs
CHANGED
|
@@ -32,33 +32,25 @@ var index_exports = {};
|
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
AvsbServer: () => AvsbServer,
|
|
34
34
|
DEFAULT_TOLERANCE_SECONDS: () => DEFAULT_TOLERANCE_SECONDS,
|
|
35
|
-
DecideOption: () =>
|
|
35
|
+
DecideOption: () => import_core13.DecideOption,
|
|
36
36
|
InMemoryStickyBucketService: () => InMemoryStickyBucketService,
|
|
37
37
|
RedisStickyBucketService: () => RedisStickyBucketService,
|
|
38
38
|
SDK_VERSION: () => SDK_VERSION,
|
|
39
|
-
consoleLogger: () =>
|
|
40
|
-
consoleTransport: () =>
|
|
39
|
+
consoleLogger: () => import_core6.consoleLogger,
|
|
40
|
+
consoleTransport: () => import_core6.consoleTransport,
|
|
41
41
|
createDecisionLog: () => createDecisionLog,
|
|
42
|
-
createLogger: () =>
|
|
42
|
+
createLogger: () => import_core6.createLogger,
|
|
43
43
|
createStreamingClient: () => import_streaming.createStreamingClient,
|
|
44
44
|
defaultLogger: () => defaultLogger,
|
|
45
45
|
expressMiddleware: () => import_express.expressMiddleware,
|
|
46
46
|
fastifyPlugin: () => import_fastify.fastifyPlugin,
|
|
47
47
|
isDevEnvironment: () => isDevEnvironment,
|
|
48
|
-
noopLogger: () =>
|
|
48
|
+
noopLogger: () => import_core6.noopLogger,
|
|
49
49
|
resolveServerLogger: () => resolveServerLogger,
|
|
50
50
|
verifyWebhookSignature: () => verifyWebhookSignature
|
|
51
51
|
});
|
|
52
52
|
module.exports = __toCommonJS(index_exports);
|
|
53
53
|
|
|
54
|
-
// src/AvsbServer.ts
|
|
55
|
-
var import_node_crypto2 = require("crypto");
|
|
56
|
-
var import_engine = require("@avsbhq/core/engine");
|
|
57
|
-
var import_core8 = require("@avsbhq/core");
|
|
58
|
-
var import_sdkKey = require("@avsbhq/utils/sdkKey");
|
|
59
|
-
var import_backoff2 = require("@avsbhq/utils/backoff");
|
|
60
|
-
var import_exposure = require("@avsbhq/utils/exposure");
|
|
61
|
-
|
|
62
54
|
// src/datasets.ts
|
|
63
55
|
var DEFAULT_REQUEST_TIMEOUT = 1e4;
|
|
64
56
|
function miss(slug, version) {
|
|
@@ -393,20 +385,140 @@ function appendDecisionLogEntry(log, input) {
|
|
|
393
385
|
return entry;
|
|
394
386
|
}
|
|
395
387
|
|
|
396
|
-
// src/
|
|
388
|
+
// src/AvsbServer.ts
|
|
389
|
+
var import_core12 = require("@avsbhq/core");
|
|
390
|
+
|
|
391
|
+
// src/UserBoundClient.ts
|
|
397
392
|
var import_core2 = require("@avsbhq/core");
|
|
398
|
-
|
|
393
|
+
function createUserBoundClient(server, context) {
|
|
394
|
+
const decisionLog = createDecisionLog();
|
|
395
|
+
const forcedDecisions = /* @__PURE__ */ new Map();
|
|
396
|
+
function evaluate(flagKey, defaultValue, expectedType, options) {
|
|
397
|
+
const forced = forcedDecisions.get(flagKey);
|
|
398
|
+
return server.evaluateInternal(flagKey, defaultValue, context, {
|
|
399
|
+
decideOptions: options?.decideOptions,
|
|
400
|
+
// A caller may point one evaluation at their own log; the bound log is
|
|
401
|
+
// the default so `getDecisionLog()` is complete without extra wiring.
|
|
402
|
+
decisionLog: options?.decisionLog ?? decisionLog,
|
|
403
|
+
...expectedType ? { expectedType } : {},
|
|
404
|
+
...forced ? { forcedVariationKey: forced.variationKey } : {}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
getFlag(flagKey, defaultValue, options) {
|
|
409
|
+
return evaluate(flagKey, defaultValue, void 0, options);
|
|
410
|
+
},
|
|
411
|
+
getBoolFlag(flagKey, defaultValue, options) {
|
|
412
|
+
return evaluate(flagKey, defaultValue, "boolean", options);
|
|
413
|
+
},
|
|
414
|
+
getStringFlag(flagKey, defaultValue, options) {
|
|
415
|
+
return evaluate(flagKey, defaultValue, "string", options);
|
|
416
|
+
},
|
|
417
|
+
getNumberFlag(flagKey, defaultValue, options) {
|
|
418
|
+
return evaluate(flagKey, defaultValue, "number", options);
|
|
419
|
+
},
|
|
420
|
+
getJsonFlag(flagKey, defaultValue, options) {
|
|
421
|
+
return evaluate(flagKey, defaultValue, "json", options);
|
|
422
|
+
},
|
|
423
|
+
getAllFlags(options) {
|
|
424
|
+
return server.getAllFlags(context, {
|
|
425
|
+
...options,
|
|
426
|
+
decisionLog: options?.decisionLog ?? decisionLog
|
|
427
|
+
});
|
|
428
|
+
},
|
|
429
|
+
track(eventKey, payload) {
|
|
430
|
+
server.track(eventKey, { ...payload, context });
|
|
431
|
+
},
|
|
432
|
+
trackPurchase(order) {
|
|
433
|
+
return server.trackPurchase((0, import_core2.bucketingKey)(context), order);
|
|
434
|
+
},
|
|
435
|
+
manualExposure(flagKey) {
|
|
436
|
+
server.manualExposure(flagKey, context);
|
|
437
|
+
},
|
|
438
|
+
getContext() {
|
|
439
|
+
return context;
|
|
440
|
+
},
|
|
441
|
+
setForcedDecision(flagKey, opts) {
|
|
442
|
+
forcedDecisions.set(flagKey, opts);
|
|
443
|
+
},
|
|
444
|
+
clearForcedDecision(flagKey) {
|
|
445
|
+
forcedDecisions.delete(flagKey);
|
|
446
|
+
},
|
|
447
|
+
getDecisionLog() {
|
|
448
|
+
return decisionLog;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/utils.ts
|
|
454
|
+
var import_utils = require("@avsbhq/utils");
|
|
455
|
+
var import_core3 = require("@avsbhq/core");
|
|
456
|
+
function buildServerUtils(server) {
|
|
457
|
+
const adapter = {
|
|
458
|
+
getFlag(flagKey, defaultValue) {
|
|
459
|
+
return (0, import_core3.notFoundFlag)(
|
|
460
|
+
defaultValue,
|
|
461
|
+
"no bound context: call server.forUser(context) and use its flag methods"
|
|
462
|
+
);
|
|
463
|
+
},
|
|
464
|
+
getAllFlags(opts) {
|
|
465
|
+
const ctx = opts?.context;
|
|
466
|
+
if (!ctx) return {};
|
|
467
|
+
return server.getAllFlags(ctx, {
|
|
468
|
+
fireExposures: opts.fireExposures
|
|
469
|
+
});
|
|
470
|
+
},
|
|
471
|
+
track(eventKey, payload) {
|
|
472
|
+
if (!payload?.context) return;
|
|
473
|
+
server.track(eventKey, {
|
|
474
|
+
...payload,
|
|
475
|
+
context: payload.context
|
|
476
|
+
});
|
|
477
|
+
},
|
|
478
|
+
on(event, cb) {
|
|
479
|
+
return server.on(event, cb);
|
|
480
|
+
},
|
|
481
|
+
getDatafile: () => server.getDatafile(),
|
|
482
|
+
getCurrentContext: () => null,
|
|
483
|
+
/**
|
|
484
|
+
* `snapshotFresh` and `refreshAndSnapshot` call this before they read, so
|
|
485
|
+
* a "fresh" snapshot really is taken against the datafile published right
|
|
486
|
+
* now. Without it both helpers quietly returned the in-memory datafile.
|
|
487
|
+
*/
|
|
488
|
+
refresh: () => server.refreshDatafile(),
|
|
489
|
+
/**
|
|
490
|
+
* Real, not a comment: `client.utils.hydrateBootstrap(blob)` on the server
|
|
491
|
+
* adopts the blob's datafile and marks the SDK ready. That is the
|
|
492
|
+
* serverless cold-start path (read a datafile from KV or a file, hand it
|
|
493
|
+
* over, evaluate on the same tick).
|
|
494
|
+
*/
|
|
495
|
+
applyDatafile: (df) => server.applyDatafileBootstrap(df)
|
|
496
|
+
// `applySnapshot` is deliberately ABSENT. A snapshot carries one context's
|
|
497
|
+
// evaluated values, and this client evaluates a different context per
|
|
498
|
+
// request, so adopting one process-wide would serve user A's values to user
|
|
499
|
+
// B. hydrateBootstrap throws with that explanation when it is handed a
|
|
500
|
+
// snapshot-only blob here, instead of quietly ignoring it.
|
|
501
|
+
};
|
|
502
|
+
return (0, import_utils.createUtils)(adapter);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/serverState.ts
|
|
506
|
+
var import_sdkKey = require("@avsbhq/utils/sdkKey");
|
|
507
|
+
|
|
508
|
+
// src/decideOptions.ts
|
|
509
|
+
var import_core4 = require("@avsbhq/core");
|
|
510
|
+
var KNOWN = Object.values(import_core4.DecideOption);
|
|
399
511
|
function createDecideOptionResolver(logger) {
|
|
400
512
|
const warned = /* @__PURE__ */ new Set();
|
|
401
513
|
return function resolve(options) {
|
|
402
514
|
if (!options || options.length === 0) return { suppressExposure: false };
|
|
403
515
|
let suppressExposure = false;
|
|
404
516
|
for (const option of options) {
|
|
405
|
-
if (option ===
|
|
517
|
+
if (option === import_core4.DecideOption.DISABLE_EXPOSURE) {
|
|
406
518
|
suppressExposure = true;
|
|
407
519
|
continue;
|
|
408
520
|
}
|
|
409
|
-
if (option ===
|
|
521
|
+
if (option === import_core4.DecideOption.INCLUDE_REASONS) {
|
|
410
522
|
continue;
|
|
411
523
|
}
|
|
412
524
|
const unknown = String(option);
|
|
@@ -468,9 +580,9 @@ function createOverrideStore() {
|
|
|
468
580
|
}
|
|
469
581
|
|
|
470
582
|
// src/datafileSource.ts
|
|
471
|
-
var
|
|
583
|
+
var import_core5 = require("@avsbhq/core");
|
|
472
584
|
async function fetchDatafile(input) {
|
|
473
|
-
const url = (0,
|
|
585
|
+
const url = (0, import_core5.datafileUrl)(input.sdkKey, input.cdnHost);
|
|
474
586
|
const headers = {};
|
|
475
587
|
if (input.etag) headers["If-None-Match"] = input.etag;
|
|
476
588
|
try {
|
|
@@ -482,8 +594,15 @@ async function fetchDatafile(input) {
|
|
|
482
594
|
);
|
|
483
595
|
return { status: "failed" };
|
|
484
596
|
}
|
|
485
|
-
const
|
|
486
|
-
|
|
597
|
+
const body = await response.json();
|
|
598
|
+
const problem = (0, import_core5.datafileShapeProblem)(body);
|
|
599
|
+
if (problem) {
|
|
600
|
+
input.logger.error(
|
|
601
|
+
`AvsB datafile from ${url} is not a datafile the SDK can read: ${problem}. The SDK keeps serving whatever datafile it already has and retries on the polling interval.`
|
|
602
|
+
);
|
|
603
|
+
return { status: "failed" };
|
|
604
|
+
}
|
|
605
|
+
return { status: "ok", datafile: body, etag: response.headers.get("etag") };
|
|
487
606
|
} catch (error) {
|
|
488
607
|
if (input.signal.aborted) return { status: "aborted" };
|
|
489
608
|
input.logger.error(
|
|
@@ -492,8 +611,16 @@ async function fetchDatafile(input) {
|
|
|
492
611
|
return { status: "failed" };
|
|
493
612
|
}
|
|
494
613
|
}
|
|
614
|
+
function acceptDatafile(candidate, origin, logger) {
|
|
615
|
+
const problem = (0, import_core5.datafileShapeProblem)(candidate);
|
|
616
|
+
if (problem === null) return candidate;
|
|
617
|
+
logger.error(
|
|
618
|
+
origin === "bootstrap" ? `AvsB ignored the bootstrap datafile passed to new AvsbServer(): ${problem}. Flags return the default you passed, with source "not_ready", until a datafile loads from the CDN. Pass the datafile JSON exactly as the CDN serves it for this SDK key.` : `AvsB ignored the datafile passed to applyDatafileBootstrap(): ${problem}. The SDK keeps serving the datafile it already has, if any. Pass the datafile JSON exactly as the CDN serves it for this SDK key.`
|
|
619
|
+
);
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
495
622
|
function sendHeartbeat(input) {
|
|
496
|
-
const endpoint = (0,
|
|
623
|
+
const endpoint = (0, import_core5.resolveHeartbeatEndpoint)(input.datafileHeartbeatEndpoint);
|
|
497
624
|
if (!endpoint) return null;
|
|
498
625
|
void fetch(endpoint, {
|
|
499
626
|
method: "POST",
|
|
@@ -509,7 +636,7 @@ function sendHeartbeat(input) {
|
|
|
509
636
|
}
|
|
510
637
|
|
|
511
638
|
// src/logger.ts
|
|
512
|
-
var
|
|
639
|
+
var import_core6 = require("@avsbhq/core");
|
|
513
640
|
function readDevGlobal() {
|
|
514
641
|
const flag = globalThis.__AVSB_DEV__;
|
|
515
642
|
return typeof flag === "boolean" ? flag : null;
|
|
@@ -526,18 +653,131 @@ function isDevEnvironment() {
|
|
|
526
653
|
return nodeEnv !== "production" && nodeEnv !== "test";
|
|
527
654
|
}
|
|
528
655
|
function defaultLogger() {
|
|
529
|
-
return isDevEnvironment() ? (0,
|
|
656
|
+
return isDevEnvironment() ? (0, import_core6.consoleLogger)("warn") : import_core6.noopLogger;
|
|
530
657
|
}
|
|
531
658
|
function resolveServerLogger(options) {
|
|
532
659
|
if (options.logger) return options.logger;
|
|
533
|
-
if (options.logLevel === "silent") return
|
|
534
|
-
if (options.logLevel) return (0,
|
|
660
|
+
if (options.logLevel === "silent") return import_core6.noopLogger;
|
|
661
|
+
if (options.logLevel) return (0, import_core6.consoleLogger)(options.logLevel);
|
|
535
662
|
return defaultLogger();
|
|
536
663
|
}
|
|
537
664
|
|
|
665
|
+
// src/serverState.ts
|
|
666
|
+
var DEFAULT_EVENTS = {
|
|
667
|
+
flushInterval: 2e3,
|
|
668
|
+
batchSize: 10,
|
|
669
|
+
maxQueueSize: 1e4,
|
|
670
|
+
retryAttempts: 3
|
|
671
|
+
};
|
|
672
|
+
function createServerState(options) {
|
|
673
|
+
const logger = resolveServerLogger(options);
|
|
674
|
+
const datafile = options.bootstrap ? acceptDatafile(options.bootstrap, "bootstrap", logger) : null;
|
|
675
|
+
const state = {
|
|
676
|
+
sdkKey: options.sdkKey,
|
|
677
|
+
pollingInterval: Math.max(options.pollingInterval ?? 6e4, 5e3),
|
|
678
|
+
cdnHost: options.cdnHost,
|
|
679
|
+
initTimeout: options.initTimeout ?? 1e4,
|
|
680
|
+
logger,
|
|
681
|
+
stickyBucketService: options.stickyBucketService ?? null,
|
|
682
|
+
eventOptions: { ...DEFAULT_EVENTS, ...options.events },
|
|
683
|
+
// Streaming is ON by default on the server (B12). Resolution happens once
|
|
684
|
+
// a datafile exists, because the endpoint is a datafile field.
|
|
685
|
+
streamingEnabled: options.streaming !== false,
|
|
686
|
+
streamingEndpointOption: options.streamingEndpoint ?? null,
|
|
687
|
+
overrides: createOverrideStore(),
|
|
688
|
+
resolveDecideOptions: createDecideOptionResolver(logger),
|
|
689
|
+
datafile,
|
|
690
|
+
datafileEtag: null,
|
|
691
|
+
ready: datafile !== null,
|
|
692
|
+
destroyed: false,
|
|
693
|
+
tracker: null,
|
|
694
|
+
streamingClient: null,
|
|
695
|
+
pollingTimer: null,
|
|
696
|
+
abortController: new AbortController(),
|
|
697
|
+
listeners: /* @__PURE__ */ new Map(),
|
|
698
|
+
readyPromise: null,
|
|
699
|
+
pendingEvents: [],
|
|
700
|
+
pendingPurchases: [],
|
|
701
|
+
pendingExposures: [],
|
|
702
|
+
lastHeartbeatAt: 0,
|
|
703
|
+
warnedOnce: /* @__PURE__ */ new Set()
|
|
704
|
+
};
|
|
705
|
+
const keyShape = (0, import_sdkKey.validateSdkKeyShape)(state.sdkKey);
|
|
706
|
+
if (!keyShape.valid && keyShape.message) {
|
|
707
|
+
logger.error(keyShape.message);
|
|
708
|
+
}
|
|
709
|
+
return state;
|
|
710
|
+
}
|
|
711
|
+
function warnOnce(s, key, message) {
|
|
712
|
+
if (s.warnedOnce.has(key)) return;
|
|
713
|
+
s.warnedOnce.add(key);
|
|
714
|
+
s.logger.warn(message);
|
|
715
|
+
}
|
|
716
|
+
function emit(s, event, ...args) {
|
|
717
|
+
const cbs = s.listeners.get(event);
|
|
718
|
+
if (!cbs) return;
|
|
719
|
+
for (const cb of cbs) {
|
|
720
|
+
try {
|
|
721
|
+
;
|
|
722
|
+
cb(...args);
|
|
723
|
+
} catch (e) {
|
|
724
|
+
s.logger.error("Listener error", { event, error: String(e) });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
function addListener(s, event, cb) {
|
|
729
|
+
if (!s.listeners.has(event)) s.listeners.set(event, /* @__PURE__ */ new Set());
|
|
730
|
+
s.listeners.get(event).add(cb);
|
|
731
|
+
return () => {
|
|
732
|
+
s.listeners.get(event)?.delete(cb);
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
function onSharedEvent(s, server, event, cb) {
|
|
736
|
+
if (event === "flagChange") {
|
|
737
|
+
warnOnce(
|
|
738
|
+
s,
|
|
739
|
+
"no-flag-change",
|
|
740
|
+
'The server SDK does not emit "flagChange": it holds one datafile for the whole process and does not diff individual flags. Subscribe to "configUpdate" for "a new datafile was published" instead.'
|
|
741
|
+
);
|
|
742
|
+
return () => void 0;
|
|
743
|
+
}
|
|
744
|
+
return server.on(
|
|
745
|
+
event,
|
|
746
|
+
cb
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/serverDatafile.ts
|
|
751
|
+
var import_backoff2 = require("@avsbhq/utils/backoff");
|
|
752
|
+
|
|
753
|
+
// src/initTimeout.ts
|
|
754
|
+
async function raceInitTimeout(work, timeoutMs) {
|
|
755
|
+
let timer;
|
|
756
|
+
const timeout = new Promise((resolve) => {
|
|
757
|
+
timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
|
|
758
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
|
|
759
|
+
});
|
|
760
|
+
try {
|
|
761
|
+
return await Promise.race([
|
|
762
|
+
work.then((value) => ({ timedOut: false, value })),
|
|
763
|
+
timeout
|
|
764
|
+
]);
|
|
765
|
+
} finally {
|
|
766
|
+
clearTimeout(timer);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/version.ts
|
|
771
|
+
var SDK_VERSION = "1.9.0";
|
|
772
|
+
var SDK_TYPE = "@avsbhq/node";
|
|
773
|
+
|
|
774
|
+
// src/serverTracking.ts
|
|
775
|
+
var import_node_crypto2 = require("crypto");
|
|
776
|
+
var import_core8 = require("@avsbhq/core");
|
|
777
|
+
|
|
538
778
|
// src/serverTracker.ts
|
|
539
779
|
var import_node_crypto = require("crypto");
|
|
540
|
-
var
|
|
780
|
+
var import_core7 = require("@avsbhq/core");
|
|
541
781
|
var import_backoff = require("@avsbhq/utils/backoff");
|
|
542
782
|
function stampExposure(event) {
|
|
543
783
|
return event.eventId ? event : { ...event, eventId: (0, import_node_crypto.randomUUID)() };
|
|
@@ -555,9 +795,9 @@ function createServerTracker(options) {
|
|
|
555
795
|
retryAttempts,
|
|
556
796
|
logger
|
|
557
797
|
} = options;
|
|
558
|
-
const batchEndpoint = (0,
|
|
559
|
-
const metricEndpoint = (0,
|
|
560
|
-
const ordersEndpoint = (0,
|
|
798
|
+
const batchEndpoint = (0, import_core7.collectUrl)("batch", collectEndpoint);
|
|
799
|
+
const metricEndpoint = (0, import_core7.collectUrl)("metricBatch", collectEndpoint);
|
|
800
|
+
const ordersEndpoint = (0, import_core7.collectUrl)("orders", collectEndpoint);
|
|
561
801
|
let exposureQueue = [];
|
|
562
802
|
let metricQueue = [];
|
|
563
803
|
let flushTimer = null;
|
|
@@ -645,7 +885,7 @@ function createServerTracker(options) {
|
|
|
645
885
|
}
|
|
646
886
|
}
|
|
647
887
|
}
|
|
648
|
-
async function
|
|
888
|
+
async function trackPurchase2(visitorId, order) {
|
|
649
889
|
if (destroyed) return;
|
|
650
890
|
const payload = {
|
|
651
891
|
...order,
|
|
@@ -694,166 +934,616 @@ function createServerTracker(options) {
|
|
|
694
934
|
}
|
|
695
935
|
await flush();
|
|
696
936
|
}
|
|
697
|
-
return { queue, queueMetric, trackPurchase, flush, destroy };
|
|
937
|
+
return { queue, queueMetric, trackPurchase: trackPurchase2, flush, destroy };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// src/serverTracking.ts
|
|
941
|
+
function trackMetric(s, eventKey, payload) {
|
|
942
|
+
if (payload.properties) {
|
|
943
|
+
warnOnce(
|
|
944
|
+
s,
|
|
945
|
+
"track-properties",
|
|
946
|
+
`track("${eventKey}") was called with properties, which are NOT stored for metric events: the ingestion contract for a conversion keeps the event name, the numeric value, the timestamp, and the visitor id. Exposure events do carry properties. Nothing else about the call is affected.`
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
if (!s.tracker) {
|
|
950
|
+
enqueuePending(s, eventKey, payload);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
s.tracker.queueMetric({
|
|
954
|
+
visitorId: (0, import_core8.bucketingKey)(payload.context),
|
|
955
|
+
eventName: eventKey,
|
|
956
|
+
// TWO columns, not one. `revenue` is money in decimal major units;
|
|
957
|
+
// `value` is the numeric metric value for average-value metrics. Storage
|
|
958
|
+
// declares both (`metric_events.revenue`, `metric_events.value`). This
|
|
959
|
+
// SDK used to ship `payload.value` into the revenue column and could
|
|
960
|
+
// never write the value column at all, so a quantity was recorded as
|
|
961
|
+
// money and every average-value metric read empty.
|
|
962
|
+
revenue: payload.revenue ?? null,
|
|
963
|
+
value: payload.value ?? null,
|
|
964
|
+
timestamp: Date.now(),
|
|
965
|
+
pageUrl: ""
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
async function trackPurchase(s, visitorId, order) {
|
|
969
|
+
if (s.destroyed) return;
|
|
970
|
+
if (!s.tracker) {
|
|
971
|
+
if (s.pendingPurchases.length >= s.eventOptions.maxQueueSize) {
|
|
972
|
+
s.pendingPurchases.shift();
|
|
973
|
+
warnOnce(
|
|
974
|
+
s,
|
|
975
|
+
"pending-purchase-full",
|
|
976
|
+
`Pre-ready purchase queue reached ${s.eventOptions.maxQueueSize} orders; the oldest is being dropped. This means the datafile never loaded: check the datafile error logged above.`
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
s.pendingPurchases.push({ visitorId, order });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
return s.tracker.trackPurchase(visitorId, order);
|
|
983
|
+
}
|
|
984
|
+
function deliverExposure(s, event) {
|
|
985
|
+
const stamped = event.eventId ? event : { ...event, eventId: (0, import_node_crypto2.randomUUID)() };
|
|
986
|
+
if (s.tracker) {
|
|
987
|
+
s.tracker.queue(stamped);
|
|
988
|
+
} else {
|
|
989
|
+
enqueuePendingExposure(s, stamped);
|
|
990
|
+
}
|
|
991
|
+
emit(s, "exposure", stamped);
|
|
992
|
+
}
|
|
993
|
+
function initTracker(s, server) {
|
|
994
|
+
if (!s.datafile || s.tracker) return;
|
|
995
|
+
const tracker = createServerTracker({
|
|
996
|
+
sdkKey: s.sdkKey,
|
|
997
|
+
collectEndpoint: s.datafile.collectEndpoint,
|
|
998
|
+
flushInterval: s.eventOptions.flushInterval,
|
|
999
|
+
batchSize: s.eventOptions.batchSize,
|
|
1000
|
+
maxQueueSize: s.eventOptions.maxQueueSize,
|
|
1001
|
+
retryAttempts: s.eventOptions.retryAttempts,
|
|
1002
|
+
logger: s.logger
|
|
1003
|
+
});
|
|
1004
|
+
s.tracker = tracker;
|
|
1005
|
+
if (s.pendingExposures.length > 0) {
|
|
1006
|
+
const queued = s.pendingExposures;
|
|
1007
|
+
s.pendingExposures = [];
|
|
1008
|
+
for (const event of queued) tracker.queue(event);
|
|
1009
|
+
}
|
|
1010
|
+
if (s.pendingEvents.length > 0) {
|
|
1011
|
+
const queued = s.pendingEvents;
|
|
1012
|
+
s.pendingEvents = [];
|
|
1013
|
+
for (const item of queued) server.track(item.eventKey, item.payload);
|
|
1014
|
+
}
|
|
1015
|
+
if (s.pendingPurchases.length > 0) {
|
|
1016
|
+
const queued = s.pendingPurchases;
|
|
1017
|
+
s.pendingPurchases = [];
|
|
1018
|
+
for (const item of queued) void server.trackPurchase(item.visitorId, item.order);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
function enqueuePendingExposure(s, event) {
|
|
1022
|
+
if (s.pendingExposures.length >= s.eventOptions.maxQueueSize) {
|
|
1023
|
+
s.pendingExposures.shift();
|
|
1024
|
+
warnOnce(
|
|
1025
|
+
s,
|
|
1026
|
+
"pending-exposures-full",
|
|
1027
|
+
`Pre-ready exposure queue reached ${s.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
s.pendingExposures.push(event);
|
|
1031
|
+
}
|
|
1032
|
+
function enqueuePending(s, eventKey, payload) {
|
|
1033
|
+
if (s.pendingEvents.length >= s.eventOptions.maxQueueSize) {
|
|
1034
|
+
s.pendingEvents.shift();
|
|
1035
|
+
warnOnce(
|
|
1036
|
+
s,
|
|
1037
|
+
"pending-events-full",
|
|
1038
|
+
`Pre-ready event queue reached ${s.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
s.pendingEvents.push({ eventKey, payload });
|
|
698
1042
|
}
|
|
699
1043
|
|
|
1044
|
+
// src/serverStreaming.ts
|
|
1045
|
+
var import_core9 = require("@avsbhq/core");
|
|
1046
|
+
|
|
700
1047
|
// src/streaming.ts
|
|
701
1048
|
var import_streaming = require("@avsbhq/utils/streaming");
|
|
702
1049
|
|
|
703
|
-
// src/
|
|
704
|
-
|
|
1050
|
+
// src/serverStreaming.ts
|
|
1051
|
+
function startStreamingIfEnabled(s, onDatafileUpdated) {
|
|
1052
|
+
if (!s.streamingEnabled || s.streamingClient || s.destroyed) return;
|
|
1053
|
+
const resolution = (0, import_core9.resolveStreamEndpoint)({
|
|
1054
|
+
optionEndpoint: s.streamingEndpointOption,
|
|
1055
|
+
datafileStreamEndpoint: s.datafile?.streamEndpoint ?? null
|
|
1056
|
+
});
|
|
1057
|
+
if (!resolution.available) {
|
|
1058
|
+
s.logger.debug(`Streaming stays off, polling continues. ${resolution.reason}`);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
s.streamingClient = (0, import_streaming.createStreamingClient)({
|
|
1062
|
+
endpoint: resolution.endpoint,
|
|
1063
|
+
sdkKey: s.sdkKey,
|
|
1064
|
+
onDatafileUpdated,
|
|
1065
|
+
logger: s.logger
|
|
1066
|
+
});
|
|
1067
|
+
s.streamingClient.connect();
|
|
1068
|
+
s.logger.debug(
|
|
1069
|
+
`Streaming datafile updates from ${resolution.endpoint} (${resolution.source}).`
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
705
1072
|
|
|
706
|
-
// src/
|
|
707
|
-
var
|
|
708
|
-
function
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1073
|
+
// src/serverDatafile.ts
|
|
1074
|
+
var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
|
|
1075
|
+
async function initializeAndReady(s, server) {
|
|
1076
|
+
if (s.ready && s.datafile) {
|
|
1077
|
+
onDatafileReadable(s, server);
|
|
1078
|
+
return { success: true, source: "bootstrap" };
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
const outcome = await raceInitTimeout(
|
|
1082
|
+
// At init there is no stored etag, so 'unchanged' cannot happen here;
|
|
1083
|
+
// it is folded in anyway so a future caching layer cannot make init
|
|
1084
|
+
// silently report failure.
|
|
1085
|
+
loadDatafile(s).then((result) => result !== "failed"),
|
|
1086
|
+
s.initTimeout
|
|
1087
|
+
);
|
|
1088
|
+
if (outcome.timedOut) {
|
|
1089
|
+
startPolling(s, server);
|
|
1090
|
+
const error2 = new Error(
|
|
1091
|
+
`AvsB init timed out after ${s.initTimeout}ms waiting for the datafile. Polling continues in the background, so flags start working as soon as the request completes; raise initTimeout if your cold start is slower.`
|
|
1092
|
+
);
|
|
1093
|
+
emit(s, "error", error2);
|
|
1094
|
+
return { success: false, source: "timeout", error: error2 };
|
|
1095
|
+
}
|
|
1096
|
+
if (outcome.value) {
|
|
1097
|
+
onDatafileReadable(s, server);
|
|
1098
|
+
return { success: true, source: "network" };
|
|
1099
|
+
}
|
|
1100
|
+
startPolling(s, server);
|
|
1101
|
+
const error = new Error(
|
|
1102
|
+
'AvsB init failed: no datafile could be loaded, so every flag serves the default you passed and flag.source is "not_ready". The datafile request error is logged above with the URL and status. Polling continues.'
|
|
1103
|
+
);
|
|
1104
|
+
emit(s, "error", error);
|
|
1105
|
+
return { success: false, source: "error", error };
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
startPolling(s, server);
|
|
1108
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
1109
|
+
emit(s, "error", error);
|
|
1110
|
+
return { success: false, source: "error", error };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
function onDatafileReadable(s, server) {
|
|
1114
|
+
initTracker(s, server);
|
|
1115
|
+
s.ready = true;
|
|
1116
|
+
startPolling(s, server);
|
|
1117
|
+
startStreamingIfEnabled(s, () => {
|
|
1118
|
+
void loadDatafile(s, "stream");
|
|
1119
|
+
});
|
|
1120
|
+
sendHeartbeat2(s);
|
|
1121
|
+
emit(s, "ready");
|
|
1122
|
+
}
|
|
1123
|
+
async function closeServer(s, drainUtils) {
|
|
1124
|
+
s.destroyed = true;
|
|
1125
|
+
s.abortController.abort();
|
|
1126
|
+
if (s.streamingClient) {
|
|
1127
|
+
s.streamingClient.disconnect();
|
|
1128
|
+
s.streamingClient = null;
|
|
1129
|
+
}
|
|
1130
|
+
if (s.pollingTimer) {
|
|
1131
|
+
clearTimeout(s.pollingTimer);
|
|
1132
|
+
s.pollingTimer = null;
|
|
1133
|
+
}
|
|
1134
|
+
if (s.tracker) {
|
|
1135
|
+
await s.tracker.destroy();
|
|
1136
|
+
s.tracker = null;
|
|
1137
|
+
}
|
|
1138
|
+
drainUtils();
|
|
1139
|
+
s.datafile = null;
|
|
1140
|
+
s.ready = false;
|
|
1141
|
+
}
|
|
1142
|
+
async function refreshDatafile(s, server) {
|
|
1143
|
+
const result = await loadDatafile(s, "manual");
|
|
1144
|
+
if (result === "failed") {
|
|
1145
|
+
s.logger.warn(
|
|
1146
|
+
"AvsB datafile refresh did not apply a new datafile; the SDK keeps serving the one it already has. The request error is logged above."
|
|
1147
|
+
);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (result === "applied" && !s.ready) onDatafileReadable(s, server);
|
|
1151
|
+
}
|
|
1152
|
+
function applyDatafileBootstrap(s, server, datafile) {
|
|
1153
|
+
if (s.destroyed) return;
|
|
1154
|
+
if (!acceptDatafile(datafile, "applyDatafileBootstrap", s.logger)) return;
|
|
1155
|
+
const changed = s.datafile?.publishedAt !== datafile.publishedAt;
|
|
1156
|
+
s.datafile = datafile;
|
|
1157
|
+
s.datafileEtag = null;
|
|
1158
|
+
if (!s.ready) {
|
|
1159
|
+
onDatafileReadable(s, server);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
initTracker(s, server);
|
|
1163
|
+
if (changed) {
|
|
1164
|
+
emit(s, "configUpdate", {
|
|
1165
|
+
publishedAt: datafile.publishedAt,
|
|
1166
|
+
reason: "manual"
|
|
720
1167
|
});
|
|
721
1168
|
}
|
|
1169
|
+
}
|
|
1170
|
+
async function loadDatafile(s, reason = "manual") {
|
|
1171
|
+
const outcome = await fetchDatafile({
|
|
1172
|
+
sdkKey: s.sdkKey,
|
|
1173
|
+
cdnHost: s.cdnHost,
|
|
1174
|
+
signal: s.abortController.signal,
|
|
1175
|
+
logger: s.logger,
|
|
1176
|
+
etag: s.datafileEtag
|
|
1177
|
+
});
|
|
1178
|
+
if (outcome.status === "ok" || outcome.status === "not-modified") {
|
|
1179
|
+
sendHeartbeat2(s);
|
|
1180
|
+
}
|
|
1181
|
+
if (outcome.status === "not-modified") return "unchanged";
|
|
1182
|
+
if (outcome.status !== "ok") return "failed";
|
|
1183
|
+
const changed = !s.datafile || s.datafile.publishedAt !== outcome.datafile.publishedAt;
|
|
1184
|
+
s.datafile = outcome.datafile;
|
|
1185
|
+
s.datafileEtag = outcome.etag;
|
|
1186
|
+
if (s.ready && changed) {
|
|
1187
|
+
emit(s, "configUpdate", {
|
|
1188
|
+
publishedAt: outcome.datafile.publishedAt,
|
|
1189
|
+
reason
|
|
1190
|
+
});
|
|
1191
|
+
s.logger.info("Datafile updated", {
|
|
1192
|
+
publishedAt: outcome.datafile.publishedAt,
|
|
1193
|
+
reason
|
|
1194
|
+
});
|
|
1195
|
+
startStreamingIfEnabled(s, () => {
|
|
1196
|
+
void loadDatafile(s, "stream");
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
return "applied";
|
|
1200
|
+
}
|
|
1201
|
+
function startPolling(s, server) {
|
|
1202
|
+
if (s.pollingTimer || s.destroyed) return;
|
|
1203
|
+
const schedule = () => {
|
|
1204
|
+
s.pollingTimer = setTimeout(() => {
|
|
1205
|
+
s.pollingTimer = null;
|
|
1206
|
+
void (async () => {
|
|
1207
|
+
const result = await loadDatafile(s, "poll");
|
|
1208
|
+
if (result === "applied" && !s.ready) onDatafileReadable(s, server);
|
|
1209
|
+
if (!s.destroyed) schedule();
|
|
1210
|
+
})();
|
|
1211
|
+
}, (0, import_backoff2.jitteredInterval)(s.pollingInterval));
|
|
1212
|
+
};
|
|
1213
|
+
schedule();
|
|
1214
|
+
}
|
|
1215
|
+
function sendHeartbeat2(s) {
|
|
1216
|
+
const now = Date.now();
|
|
1217
|
+
if (now - s.lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
|
|
1218
|
+
const posted = sendHeartbeat({
|
|
1219
|
+
sdkKey: s.sdkKey,
|
|
1220
|
+
sdkType: SDK_TYPE,
|
|
1221
|
+
sdkVersion: SDK_VERSION,
|
|
1222
|
+
datafileHeartbeatEndpoint: s.datafile?.heartbeatEndpoint,
|
|
1223
|
+
signal: s.abortController.signal
|
|
1224
|
+
});
|
|
1225
|
+
if (posted) s.lastHeartbeatAt = now;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// src/serverEvaluation.ts
|
|
1229
|
+
var import_core11 = require("@avsbhq/core");
|
|
1230
|
+
|
|
1231
|
+
// src/serverDecision.ts
|
|
1232
|
+
var import_engine = require("@avsbhq/core/engine");
|
|
1233
|
+
var import_core10 = require("@avsbhq/core");
|
|
1234
|
+
var import_exposure = require("@avsbhq/utils/exposure");
|
|
1235
|
+
function evaluateDecision(s, flagKey, uc, defaultValue) {
|
|
1236
|
+
const reasons = [];
|
|
1237
|
+
const overrideKey = s.overrides.resolve(flagKey, uc.userId);
|
|
1238
|
+
const rtOver = overrideKey ? findVariation(s, flagKey, overrideKey) : null;
|
|
1239
|
+
if (rtOver) {
|
|
1240
|
+
reasons.push(`matched runtime override for flagKey="${flagKey}"`);
|
|
1241
|
+
return {
|
|
1242
|
+
value: rtOver.value,
|
|
1243
|
+
variationKey: rtOver.key,
|
|
1244
|
+
source: "runtimeOverride",
|
|
1245
|
+
ruleId: null,
|
|
1246
|
+
ruleType: null,
|
|
1247
|
+
reasons
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
if (s.stickyBucketService) {
|
|
1251
|
+
const sticky = resolveStickyBucket(s, flagKey, uc.userId);
|
|
1252
|
+
if (sticky !== void 0) {
|
|
1253
|
+
reasons.push(`matched sticky bucket assignment for userId="${uc.userId}"`);
|
|
1254
|
+
if (!uc.suppressExposure) {
|
|
1255
|
+
fireExposureForStickyAssignment(s, flagKey, sticky, uc);
|
|
1256
|
+
}
|
|
1257
|
+
return {
|
|
1258
|
+
value: sticky.vref.value,
|
|
1259
|
+
variationKey: sticky.vref.key,
|
|
1260
|
+
source: "sticky",
|
|
1261
|
+
ruleId: sticky.assignment.ruleId,
|
|
1262
|
+
ruleType: sticky.assignment.ruleType,
|
|
1263
|
+
reasons
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
|
|
1268
|
+
const onExposure = uc.suppressExposure ? void 0 : (event) => {
|
|
1269
|
+
deliverExposure(s, (0, import_exposure.redactExposure)(event, uc.privateAttributes));
|
|
1270
|
+
};
|
|
1271
|
+
const r = (0, import_engine.evaluateFlag)(
|
|
1272
|
+
flagKey,
|
|
1273
|
+
defaultValue,
|
|
1274
|
+
legacyCtx,
|
|
1275
|
+
// Callers check readiness first, so a datafile is loaded here.
|
|
1276
|
+
s.datafile,
|
|
1277
|
+
onExposure,
|
|
1278
|
+
reasons
|
|
1279
|
+
);
|
|
1280
|
+
if (s.stickyBucketService && r.variationKey && r.source === "rule" && r.ruleId && r.ruleType === "ab_test") {
|
|
1281
|
+
const v = findVariation(s, flagKey, r.variationKey);
|
|
1282
|
+
if (v) {
|
|
1283
|
+
s.stickyBucketService.save(uc.userId, flagKey, {
|
|
1284
|
+
variationId: v.id,
|
|
1285
|
+
ruleId: r.ruleId,
|
|
1286
|
+
ruleType: r.ruleType,
|
|
1287
|
+
assignedAt: Date.now()
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
722
1291
|
return {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
getStringFlag(flagKey, defaultValue, options) {
|
|
730
|
-
return evaluate(flagKey, defaultValue, "string", options);
|
|
731
|
-
},
|
|
732
|
-
getNumberFlag(flagKey, defaultValue, options) {
|
|
733
|
-
return evaluate(flagKey, defaultValue, "number", options);
|
|
734
|
-
},
|
|
735
|
-
getJsonFlag(flagKey, defaultValue, options) {
|
|
736
|
-
return evaluate(flagKey, defaultValue, "json", options);
|
|
737
|
-
},
|
|
738
|
-
getAllFlags(options) {
|
|
739
|
-
return server.getAllFlags(context, {
|
|
740
|
-
...options,
|
|
741
|
-
decisionLog: options?.decisionLog ?? decisionLog
|
|
742
|
-
});
|
|
743
|
-
},
|
|
744
|
-
track(eventKey, payload) {
|
|
745
|
-
server.track(eventKey, { ...payload, context });
|
|
746
|
-
},
|
|
747
|
-
trackPurchase(order) {
|
|
748
|
-
return server.trackPurchase((0, import_core6.bucketingKey)(context), order);
|
|
749
|
-
},
|
|
750
|
-
manualExposure(flagKey) {
|
|
751
|
-
server.manualExposure(flagKey, context);
|
|
752
|
-
},
|
|
753
|
-
getContext() {
|
|
754
|
-
return context;
|
|
755
|
-
},
|
|
756
|
-
setForcedDecision(flagKey, opts) {
|
|
757
|
-
forcedDecisions.set(flagKey, opts);
|
|
758
|
-
},
|
|
759
|
-
clearForcedDecision(flagKey) {
|
|
760
|
-
forcedDecisions.delete(flagKey);
|
|
761
|
-
},
|
|
762
|
-
getDecisionLog() {
|
|
763
|
-
return decisionLog;
|
|
764
|
-
}
|
|
1292
|
+
value: r.value,
|
|
1293
|
+
variationKey: r.variationKey,
|
|
1294
|
+
source: (0, import_core10.mapLegacySource)(r.source),
|
|
1295
|
+
ruleId: r.ruleId,
|
|
1296
|
+
ruleType: r.ruleType,
|
|
1297
|
+
reasons
|
|
765
1298
|
};
|
|
766
1299
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
);
|
|
778
|
-
},
|
|
779
|
-
getAllFlags(opts) {
|
|
780
|
-
const ctx = opts?.context;
|
|
781
|
-
if (!ctx) return {};
|
|
782
|
-
return server.getAllFlags(ctx, {
|
|
783
|
-
fireExposures: opts.fireExposures
|
|
784
|
-
});
|
|
785
|
-
},
|
|
786
|
-
track(eventKey, payload) {
|
|
787
|
-
if (!payload?.context) return;
|
|
788
|
-
server.track(eventKey, {
|
|
789
|
-
...payload,
|
|
790
|
-
context: payload.context
|
|
791
|
-
});
|
|
792
|
-
},
|
|
793
|
-
on(event, cb) {
|
|
794
|
-
return server.on(event, cb);
|
|
795
|
-
},
|
|
796
|
-
getDatafile: () => server.getDatafile(),
|
|
797
|
-
getCurrentContext: () => null,
|
|
798
|
-
/**
|
|
799
|
-
* Real, not a comment: `client.utils.hydrateBootstrap(blob)` on the server
|
|
800
|
-
* adopts the blob's datafile and marks the SDK ready. That is the
|
|
801
|
-
* serverless cold-start path (read a datafile from KV or a file, hand it
|
|
802
|
-
* over, evaluate on the same tick).
|
|
803
|
-
*/
|
|
804
|
-
applyDatafile: (df) => server.applyDatafileBootstrap(df)
|
|
805
|
-
// `applySnapshot` is deliberately ABSENT. A snapshot carries one context's
|
|
806
|
-
// evaluated values, and this client evaluates a different context per
|
|
807
|
-
// request, so adopting one process-wide would serve user A's values to user
|
|
808
|
-
// B. hydrateBootstrap throws with that explanation when it is handed a
|
|
809
|
-
// snapshot-only blob here, instead of quietly ignoring it.
|
|
1300
|
+
function buildLegacyContext(context, suppressExposure) {
|
|
1301
|
+
const legacy = (0, import_core10.toLegacyEvalContext)(context);
|
|
1302
|
+
const userId = legacy.attributes["userId"] ?? "";
|
|
1303
|
+
const privateAttrs = (0, import_core10.privateAttributesFrom)(context);
|
|
1304
|
+
const { userId: _uid, ...rest } = legacy.attributes;
|
|
1305
|
+
return {
|
|
1306
|
+
userId,
|
|
1307
|
+
attributes: rest,
|
|
1308
|
+
suppressExposure,
|
|
1309
|
+
privateAttributes: privateAttrs.size > 0 ? [...privateAttrs] : void 0
|
|
810
1310
|
};
|
|
811
|
-
|
|
1311
|
+
}
|
|
1312
|
+
function manualExposure(s, server, flagKey, context) {
|
|
1313
|
+
if (!server.isReady() || !s.datafile || !s.tracker) return;
|
|
1314
|
+
const legacyCtx = buildLegacyContext(context, true);
|
|
1315
|
+
const result = evaluateDecision(s, flagKey, legacyCtx, void 0);
|
|
1316
|
+
if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
|
|
1317
|
+
if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
|
|
1318
|
+
const flag = s.datafile.flags.find((f) => f.key === flagKey);
|
|
1319
|
+
if (!flag) return;
|
|
1320
|
+
const variation = flag.variations.find((v) => v.key === result.variationKey);
|
|
1321
|
+
if (!variation) return;
|
|
1322
|
+
deliverExposure(s, {
|
|
1323
|
+
visitorId: (0, import_core10.bucketingKey)(context),
|
|
1324
|
+
experimentId: result.ruleId,
|
|
1325
|
+
variationId: variation.id,
|
|
1326
|
+
eventName: "flag_exposure",
|
|
1327
|
+
revenue: null,
|
|
1328
|
+
timestamp: Date.now(),
|
|
1329
|
+
pageUrl: "",
|
|
1330
|
+
properties: { flagKey, flagId: flag.id, manuallyFired: true }
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
function resolveStickyBucket(s, flagKey, userId) {
|
|
1334
|
+
if (!s.stickyBucketService || !s.datafile) return void 0;
|
|
1335
|
+
const assignment = s.stickyBucketService.lookup(userId, flagKey);
|
|
1336
|
+
if (!assignment) return void 0;
|
|
1337
|
+
const flag = s.datafile.flags.find((f) => f.key === flagKey);
|
|
1338
|
+
if (!flag) return void 0;
|
|
1339
|
+
const v = flag.variations.find((x) => x.id === assignment.variationId);
|
|
1340
|
+
if (!v) return void 0;
|
|
1341
|
+
return { vref: { value: v.value, key: v.key, id: v.id }, assignment };
|
|
1342
|
+
}
|
|
1343
|
+
function findVariation(s, flagKey, variationKey) {
|
|
1344
|
+
if (!s.datafile) return null;
|
|
1345
|
+
const flag = s.datafile.flags.find((f) => f.key === flagKey);
|
|
1346
|
+
if (!flag) return null;
|
|
1347
|
+
const v = flag.variations.find((x) => x.key === variationKey);
|
|
1348
|
+
if (!v) return null;
|
|
1349
|
+
return { value: v.value, key: v.key, id: v.id };
|
|
1350
|
+
}
|
|
1351
|
+
function fireExposureForStickyAssignment(s, flagKey, sticky, uc) {
|
|
1352
|
+
if (!s.tracker || !s.datafile) return;
|
|
1353
|
+
const flag = s.datafile.flags.find((f) => f.key === flagKey);
|
|
1354
|
+
if (!flag) return;
|
|
1355
|
+
const rule = flag.rules.find((r) => r.id === sticky.assignment.ruleId);
|
|
1356
|
+
if (!rule || !rule.variations.some((rv) => rv.variationId === sticky.vref.id)) {
|
|
1357
|
+
s.logger.warn(
|
|
1358
|
+
"Sticky assignment references a rule no longer matching the datafile",
|
|
1359
|
+
{
|
|
1360
|
+
flagKey,
|
|
1361
|
+
userId: uc.userId,
|
|
1362
|
+
assignedRuleId: sticky.assignment.ruleId,
|
|
1363
|
+
assignedVariationId: sticky.vref.id
|
|
1364
|
+
}
|
|
1365
|
+
);
|
|
1366
|
+
emit(s, "stickyOrphan", {
|
|
1367
|
+
flagKey,
|
|
1368
|
+
userId: uc.userId,
|
|
1369
|
+
assignment: sticky.assignment
|
|
1370
|
+
});
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
deliverExposure(s, {
|
|
1374
|
+
visitorId: uc.userId,
|
|
1375
|
+
experimentId: rule.id,
|
|
1376
|
+
variationId: sticky.vref.id,
|
|
1377
|
+
eventName: "flag_exposure",
|
|
1378
|
+
revenue: null,
|
|
1379
|
+
timestamp: Date.now(),
|
|
1380
|
+
pageUrl: "",
|
|
1381
|
+
properties: { flagKey, flagId: flag.id }
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// src/serverEvaluation.ts
|
|
1386
|
+
function evaluateServerFlag(s, server, flagKey, defaultValue, context, options) {
|
|
1387
|
+
if (!context) {
|
|
1388
|
+
warnOnce(
|
|
1389
|
+
s,
|
|
1390
|
+
"no-context",
|
|
1391
|
+
`getFlag("${flagKey}") was called without a context, so it cannot be evaluated and returns the default you passed. Bind one per request with server.forUser({ kind: "user", key: userId }), or pass it as the third argument: server.getFlag(key, default, { kind: "user", key: userId }).`
|
|
1392
|
+
);
|
|
1393
|
+
return (0, import_core11.notReadyFlag)(
|
|
1394
|
+
defaultValue,
|
|
1395
|
+
"no context passed; call server.forUser(context) or pass the context positionally"
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
if (!server.isReady() || !s.datafile) {
|
|
1399
|
+
warnOnce(
|
|
1400
|
+
s,
|
|
1401
|
+
"not-ready",
|
|
1402
|
+
`getFlag("${flagKey}") was called before the datafile loaded, so it returns the default you passed and flag.source is "not_ready". Await server.onReady() during boot, or pass a bootstrap datafile to the constructor for an instant start.`
|
|
1403
|
+
);
|
|
1404
|
+
return (0, import_core11.notReadyFlag)(defaultValue, "SDK not ready: no datafile loaded yet");
|
|
1405
|
+
}
|
|
1406
|
+
const decide = s.resolveDecideOptions(options?.decideOptions);
|
|
1407
|
+
const flag = options?.forcedVariationKey ? buildForcedFlag(
|
|
1408
|
+
s,
|
|
1409
|
+
flagKey,
|
|
1410
|
+
options.forcedVariationKey,
|
|
1411
|
+
defaultValue,
|
|
1412
|
+
context,
|
|
1413
|
+
options
|
|
1414
|
+
) : evaluateAndBuild(
|
|
1415
|
+
s,
|
|
1416
|
+
flagKey,
|
|
1417
|
+
defaultValue,
|
|
1418
|
+
context,
|
|
1419
|
+
decide,
|
|
1420
|
+
options
|
|
1421
|
+
);
|
|
1422
|
+
return flag;
|
|
1423
|
+
}
|
|
1424
|
+
function evaluateAndBuild(s, flagKey, defaultValue, context, decide, options) {
|
|
1425
|
+
const legacyCtx = buildLegacyContext(context, decide.suppressExposure);
|
|
1426
|
+
const result = evaluateDecision(s, flagKey, legacyCtx, defaultValue);
|
|
1427
|
+
if (result.source === "not_found") {
|
|
1428
|
+
warnOnce(s, `missing:${flagKey}`, (0, import_core11.flagNotFoundMessage)(flagKey));
|
|
1429
|
+
}
|
|
1430
|
+
const typeMismatch = checkType(s, flagKey, options?.expectedType, result);
|
|
1431
|
+
if (typeMismatch) {
|
|
1432
|
+
s.logger.warn(
|
|
1433
|
+
`Type mismatch on flag "${flagKey}": ${typeMismatch}; returning the default.`
|
|
1434
|
+
);
|
|
1435
|
+
const fallback = (0, import_core11.notFoundFlag)(defaultValue, typeMismatch);
|
|
1436
|
+
recordDecision(s, flagKey, fallback, context, options);
|
|
1437
|
+
return fallback;
|
|
1438
|
+
}
|
|
1439
|
+
const flag = toFlag(result, defaultValue);
|
|
1440
|
+
recordDecision(s, flagKey, flag, context, options);
|
|
1441
|
+
return flag;
|
|
1442
|
+
}
|
|
1443
|
+
function checkType(s, flagKey, expectedType, result) {
|
|
1444
|
+
if (!expectedType) return null;
|
|
1445
|
+
if (result.source === "not_found" || result.source === "disabled") return null;
|
|
1446
|
+
const datafileFlag = (0, import_core11.findDatafileFlag)(s.datafile, flagKey);
|
|
1447
|
+
return (0, import_core11.checkExpectedType)(datafileFlag, expectedType, result.value) ?? null;
|
|
1448
|
+
}
|
|
1449
|
+
function toFlag(result, defaultValue) {
|
|
1450
|
+
return (0, import_core11.createFlag)({
|
|
1451
|
+
value: result.value ?? defaultValue,
|
|
1452
|
+
variationKey: result.variationKey,
|
|
1453
|
+
source: result.source,
|
|
1454
|
+
ruleId: result.ruleId,
|
|
1455
|
+
ruleType: result.ruleType,
|
|
1456
|
+
reasons: result.reasons,
|
|
1457
|
+
evaluatedAt: Date.now(),
|
|
1458
|
+
durationMicros: 0
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
function buildForcedFlag(s, flagKey, variationKey, defaultValue, context, options) {
|
|
1462
|
+
const vref = findVariation(s, flagKey, variationKey);
|
|
1463
|
+
const flag = (0, import_core11.createFlag)({
|
|
1464
|
+
value: vref ? vref.value : defaultValue,
|
|
1465
|
+
variationKey: vref ? vref.key : null,
|
|
1466
|
+
source: "runtimeOverride",
|
|
1467
|
+
ruleId: null,
|
|
1468
|
+
ruleType: null,
|
|
1469
|
+
reasons: [`forced decision: variationKey="${variationKey}"`]
|
|
1470
|
+
});
|
|
1471
|
+
recordDecision(s, flagKey, flag, context, options);
|
|
1472
|
+
return flag;
|
|
1473
|
+
}
|
|
1474
|
+
function recordDecision(s, flagKey, flag, context, options) {
|
|
1475
|
+
const summary = (0, import_core11.contextSummary)(context);
|
|
1476
|
+
emit(s, "evaluation", {
|
|
1477
|
+
flagKey,
|
|
1478
|
+
flag,
|
|
1479
|
+
contextKeys: summary.map((entry) => entry.key)
|
|
1480
|
+
});
|
|
1481
|
+
if (!options?.decisionLog) return;
|
|
1482
|
+
appendDecisionLogEntry(options.decisionLog, {
|
|
1483
|
+
flagKey,
|
|
1484
|
+
value: flag.value,
|
|
1485
|
+
variationKey: flag.variationKey,
|
|
1486
|
+
source: flag.source,
|
|
1487
|
+
ruleId: flag.ruleId,
|
|
1488
|
+
ruleType: flag.ruleType,
|
|
1489
|
+
reasons: flag.reasons,
|
|
1490
|
+
evaluatedAt: flag.evaluatedAt,
|
|
1491
|
+
durationMicros: flag.durationMicros,
|
|
1492
|
+
contextSummary: summary,
|
|
1493
|
+
privateValues: privateAttributeValues(context)
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
function evaluateAllServerFlags(s, server, context, options) {
|
|
1497
|
+
if (!context) {
|
|
1498
|
+
warnOnce(
|
|
1499
|
+
s,
|
|
1500
|
+
"get-all-no-context",
|
|
1501
|
+
"getAllFlags() was called without a context and returned an empty object. Pass the context positionally, or use server.forUser(context).getAllFlags()."
|
|
1502
|
+
);
|
|
1503
|
+
return {};
|
|
1504
|
+
}
|
|
1505
|
+
if (!server.isReady() || !s.datafile) {
|
|
1506
|
+
warnOnce(
|
|
1507
|
+
s,
|
|
1508
|
+
"get-all-not-ready",
|
|
1509
|
+
"getAllFlags() was called before the datafile loaded and returned an empty object, which is indistinguishable from a project with no flags. Await server.onReady() first."
|
|
1510
|
+
);
|
|
1511
|
+
return {};
|
|
1512
|
+
}
|
|
1513
|
+
const fireExposures = options?.fireExposures === true;
|
|
1514
|
+
const decideOptions = [...options?.decideOptions ?? []];
|
|
1515
|
+
if (!fireExposures && !decideOptions.includes("DISABLE_EXPOSURE")) {
|
|
1516
|
+
decideOptions.push("DISABLE_EXPOSURE");
|
|
1517
|
+
}
|
|
1518
|
+
const out = {};
|
|
1519
|
+
for (const entry of s.datafile.flags) {
|
|
1520
|
+
out[entry.key] = server.evaluateInternal(entry.key, void 0, context, {
|
|
1521
|
+
decideOptions,
|
|
1522
|
+
decisionLog: options?.decisionLog
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
return out;
|
|
812
1526
|
}
|
|
813
1527
|
|
|
814
1528
|
// src/AvsbServer.ts
|
|
815
|
-
var DEFAULT_EVENTS = {
|
|
816
|
-
flushInterval: 2e3,
|
|
817
|
-
batchSize: 10,
|
|
818
|
-
maxQueueSize: 1e4,
|
|
819
|
-
retryAttempts: 3
|
|
820
|
-
};
|
|
821
|
-
var SDK_TYPE = "@avsbhq/node";
|
|
822
|
-
var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
|
|
823
1529
|
var AvsbServer = class {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
pollingTimer;
|
|
842
|
-
abortController = new AbortController();
|
|
843
|
-
listeners = /* @__PURE__ */ new Map();
|
|
844
|
-
readyPromise = null;
|
|
845
|
-
pendingEvents = [];
|
|
846
|
-
pendingPurchases = [];
|
|
847
|
-
/**
|
|
848
|
-
* Exposures produced before the tracker existed. Third member of the
|
|
849
|
-
* pre-ready family alongside `pendingEvents` and `pendingPurchases`; it used
|
|
850
|
-
* to be missing, and exposures were dropped on the floor instead.
|
|
851
|
-
*/
|
|
852
|
-
pendingExposures = [];
|
|
853
|
-
/** ms-epoch of the last liveness heartbeat, for the throttle. */
|
|
854
|
-
lastHeartbeatAt = 0;
|
|
855
|
-
/** Keys of one-time warnings, so a request loop cannot flood a log. */
|
|
856
|
-
warnedOnce = /* @__PURE__ */ new Set();
|
|
1530
|
+
/** Everything mutable about this client. See ./serverState. */
|
|
1531
|
+
s;
|
|
1532
|
+
// Private seams kept on the instance because
|
|
1533
|
+
// AvsbServer.exposureDelivery.test.ts reaches them directly to stage a
|
|
1534
|
+
// missing tracker. Each one is a straight pass-through to the state.
|
|
1535
|
+
get tracker() {
|
|
1536
|
+
return this.s.tracker;
|
|
1537
|
+
}
|
|
1538
|
+
set tracker(tracker) {
|
|
1539
|
+
this.s.tracker = tracker;
|
|
1540
|
+
}
|
|
1541
|
+
deliverExposure(event) {
|
|
1542
|
+
deliverExposure(this.s, event);
|
|
1543
|
+
}
|
|
1544
|
+
initTracker() {
|
|
1545
|
+
initTracker(this.s, this);
|
|
1546
|
+
}
|
|
857
1547
|
/**
|
|
858
1548
|
* `client.utils.*` surface, constructed lazily on first access so the utils
|
|
859
1549
|
* package is only imported when actually consumed.
|
|
@@ -865,9 +1555,10 @@ var AvsbServer = class {
|
|
|
865
1555
|
getAllFlags: (ctx, opts) => this.getAllFlags(ctx, opts),
|
|
866
1556
|
getFlag: (flagKey, defaultValue, context) => this.getFlag(flagKey, defaultValue, context),
|
|
867
1557
|
track: (eventKey, payload) => this.track(eventKey, payload),
|
|
868
|
-
on: (event, cb) => this.
|
|
869
|
-
getDatafile: () => this.datafile,
|
|
870
|
-
applyDatafileBootstrap: (df) => this.applyDatafileBootstrap(df)
|
|
1558
|
+
on: (event, cb) => onSharedEvent(this.s, this, event, cb),
|
|
1559
|
+
getDatafile: () => this.s.datafile,
|
|
1560
|
+
applyDatafileBootstrap: (df) => this.applyDatafileBootstrap(df),
|
|
1561
|
+
refreshDatafile: () => this.refreshDatafile()
|
|
871
1562
|
});
|
|
872
1563
|
}
|
|
873
1564
|
return this._utils;
|
|
@@ -880,9 +1571,9 @@ var AvsbServer = class {
|
|
|
880
1571
|
get datasets() {
|
|
881
1572
|
if (!this._datasets) {
|
|
882
1573
|
this._datasets = createDatasetsClient({
|
|
883
|
-
sdkKey: this.sdkKey,
|
|
884
|
-
getDatafile: () => this.datafile,
|
|
885
|
-
logger: this.logger
|
|
1574
|
+
sdkKey: this.s.sdkKey,
|
|
1575
|
+
getDatafile: () => this.s.datafile,
|
|
1576
|
+
logger: this.s.logger
|
|
886
1577
|
});
|
|
887
1578
|
}
|
|
888
1579
|
return this._datasets;
|
|
@@ -897,132 +1588,38 @@ var AvsbServer = class {
|
|
|
897
1588
|
get recs() {
|
|
898
1589
|
if (!this._recs) {
|
|
899
1590
|
this._recs = createRecsClient({
|
|
900
|
-
sdkKey: this.sdkKey,
|
|
901
|
-
getDatafile: () => this.datafile,
|
|
902
|
-
getTracker: () => this.tracker,
|
|
903
|
-
logger: this.logger
|
|
1591
|
+
sdkKey: this.s.sdkKey,
|
|
1592
|
+
getDatafile: () => this.s.datafile,
|
|
1593
|
+
getTracker: () => this.s.tracker,
|
|
1594
|
+
logger: this.s.logger
|
|
904
1595
|
});
|
|
905
1596
|
}
|
|
906
1597
|
return this._recs;
|
|
907
1598
|
}
|
|
908
1599
|
constructor(options) {
|
|
909
|
-
this.
|
|
910
|
-
this.
|
|
911
|
-
this.cdnHost = options.cdnHost;
|
|
912
|
-
this.initTimeout = options.initTimeout ?? 1e4;
|
|
913
|
-
this.logger = resolveServerLogger(options);
|
|
914
|
-
this.stickyBucketService = options.stickyBucketService ?? null;
|
|
915
|
-
this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
|
|
916
|
-
this.streamingEnabled = options.streaming !== false;
|
|
917
|
-
this.streamingEndpointOption = options.streamingEndpoint ?? null;
|
|
918
|
-
this.datafile = options.bootstrap ?? null;
|
|
919
|
-
this.ready = !!options.bootstrap;
|
|
920
|
-
this.destroyed = false;
|
|
921
|
-
this.tracker = null;
|
|
922
|
-
this.streamingClient = null;
|
|
923
|
-
this.pollingTimer = null;
|
|
924
|
-
this.resolveDecideOptions = createDecideOptionResolver(this.logger);
|
|
925
|
-
const keyShape = (0, import_sdkKey.validateSdkKeyShape)(this.sdkKey);
|
|
926
|
-
if (!keyShape.valid && keyShape.message) {
|
|
927
|
-
this.logger.error(keyShape.message);
|
|
928
|
-
}
|
|
929
|
-
if (this.datafile) this.initTracker();
|
|
1600
|
+
this.s = createServerState(options);
|
|
1601
|
+
if (this.s.datafile) this.initTracker();
|
|
930
1602
|
}
|
|
931
|
-
//
|
|
932
|
-
// Readiness
|
|
933
|
-
// ---------------------------------------------------------------------------
|
|
1603
|
+
// ---- Readiness ----
|
|
934
1604
|
/**
|
|
935
1605
|
* Await the init attempt. Never rejects; always resolves to an InitResult.
|
|
936
1606
|
* Calling it twice returns the same promise.
|
|
937
1607
|
*/
|
|
938
1608
|
onReady() {
|
|
939
|
-
if (this.readyPromise) return this.readyPromise;
|
|
940
|
-
this.readyPromise = this.
|
|
941
|
-
return this.readyPromise;
|
|
1609
|
+
if (this.s.readyPromise) return this.s.readyPromise;
|
|
1610
|
+
this.s.readyPromise = initializeAndReady(this.s, this);
|
|
1611
|
+
return this.s.readyPromise;
|
|
942
1612
|
}
|
|
943
1613
|
/** True when a datafile is loaded and flags are answerable right now. */
|
|
944
1614
|
isReady() {
|
|
945
|
-
return this.ready && this.datafile !== null;
|
|
946
|
-
}
|
|
947
|
-
async initializeAndReady() {
|
|
948
|
-
if (this.ready && this.datafile) {
|
|
949
|
-
this.onDatafileReadable();
|
|
950
|
-
return { success: true, source: "bootstrap" };
|
|
951
|
-
}
|
|
952
|
-
try {
|
|
953
|
-
const outcome = await Promise.race([
|
|
954
|
-
// At init there is no stored etag, so 'unchanged' cannot happen here;
|
|
955
|
-
// it is folded in anyway so a future caching layer cannot make init
|
|
956
|
-
// silently report failure.
|
|
957
|
-
this.loadDatafile().then((result) => ({
|
|
958
|
-
applied: result !== "failed",
|
|
959
|
-
timedOut: false
|
|
960
|
-
})),
|
|
961
|
-
new Promise(
|
|
962
|
-
(resolve) => setTimeout(
|
|
963
|
-
() => resolve({ applied: false, timedOut: true }),
|
|
964
|
-
this.initTimeout
|
|
965
|
-
)
|
|
966
|
-
)
|
|
967
|
-
]);
|
|
968
|
-
if (outcome.timedOut) {
|
|
969
|
-
this.startPolling();
|
|
970
|
-
const error2 = new Error(
|
|
971
|
-
`AvsB init timed out after ${this.initTimeout}ms waiting for the datafile. Polling continues in the background, so flags start working as soon as the request completes; raise initTimeout if your cold start is slower.`
|
|
972
|
-
);
|
|
973
|
-
this.emit("error", error2);
|
|
974
|
-
return { success: false, source: "timeout", error: error2 };
|
|
975
|
-
}
|
|
976
|
-
if (outcome.applied) {
|
|
977
|
-
this.onDatafileReadable();
|
|
978
|
-
return { success: true, source: "network" };
|
|
979
|
-
}
|
|
980
|
-
this.startPolling();
|
|
981
|
-
const error = new Error(
|
|
982
|
-
'AvsB init failed: no datafile could be loaded, so every flag serves the default you passed and flag.source is "not_ready". The datafile request error is logged above with the URL and status. Polling continues.'
|
|
983
|
-
);
|
|
984
|
-
this.emit("error", error);
|
|
985
|
-
return { success: false, source: "error", error };
|
|
986
|
-
} catch (e) {
|
|
987
|
-
this.startPolling();
|
|
988
|
-
const error = e instanceof Error ? e : new Error(String(e));
|
|
989
|
-
this.emit("error", error);
|
|
990
|
-
return { success: false, source: "error", error };
|
|
991
|
-
}
|
|
1615
|
+
return this.s.ready && this.s.datafile !== null;
|
|
992
1616
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
this.
|
|
996
|
-
this.ready = true;
|
|
997
|
-
this.startPolling();
|
|
998
|
-
this.startStreamingIfEnabled();
|
|
999
|
-
this.sendHeartbeat();
|
|
1000
|
-
this.emit("ready");
|
|
1001
|
-
}
|
|
1002
|
-
// ---------------------------------------------------------------------------
|
|
1003
|
-
// Lifecycle
|
|
1004
|
-
// ---------------------------------------------------------------------------
|
|
1005
|
-
async close() {
|
|
1006
|
-
this.destroyed = true;
|
|
1007
|
-
this.abortController.abort();
|
|
1008
|
-
if (this.streamingClient) {
|
|
1009
|
-
this.streamingClient.disconnect();
|
|
1010
|
-
this.streamingClient = null;
|
|
1011
|
-
}
|
|
1012
|
-
if (this.pollingTimer) {
|
|
1013
|
-
clearTimeout(this.pollingTimer);
|
|
1014
|
-
this.pollingTimer = null;
|
|
1015
|
-
}
|
|
1016
|
-
if (this.tracker) {
|
|
1017
|
-
await this.tracker.destroy();
|
|
1018
|
-
this.tracker = null;
|
|
1019
|
-
}
|
|
1020
|
-
this._utils?._drain();
|
|
1021
|
-
this.datafile = null;
|
|
1022
|
-
this.ready = false;
|
|
1617
|
+
// ---- Lifecycle ----
|
|
1618
|
+
close() {
|
|
1619
|
+
return closeServer(this.s, () => this._utils?._drain());
|
|
1023
1620
|
}
|
|
1024
1621
|
async flush() {
|
|
1025
|
-
if (this.tracker) await this.tracker.flush();
|
|
1622
|
+
if (this.s.tracker) await this.s.tracker.flush();
|
|
1026
1623
|
}
|
|
1027
1624
|
/**
|
|
1028
1625
|
* Fetch the datafile now and apply it if it changed.
|
|
@@ -1030,15 +1627,8 @@ var AvsbServer = class {
|
|
|
1030
1627
|
* A 304 is not a problem and is not warned about: it means the datafile in
|
|
1031
1628
|
* memory IS the published one.
|
|
1032
1629
|
*/
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
if (result === "failed") {
|
|
1036
|
-
this.logger.warn(
|
|
1037
|
-
"AvsB datafile refresh did not apply a new datafile; the SDK keeps serving the one it already has. The request error is logged above."
|
|
1038
|
-
);
|
|
1039
|
-
return;
|
|
1040
|
-
}
|
|
1041
|
-
if (result === "applied" && !this.ready) this.onDatafileReadable();
|
|
1630
|
+
refreshDatafile() {
|
|
1631
|
+
return refreshDatafile(this.s, this);
|
|
1042
1632
|
}
|
|
1043
1633
|
/**
|
|
1044
1634
|
* Adopt a datafile received out of band: a bootstrap blob from
|
|
@@ -1050,33 +1640,16 @@ var AvsbServer = class {
|
|
|
1050
1640
|
* handoff silently do nothing.
|
|
1051
1641
|
*/
|
|
1052
1642
|
applyDatafileBootstrap(datafile) {
|
|
1053
|
-
|
|
1054
|
-
const changed = this.datafile?.publishedAt !== datafile.publishedAt;
|
|
1055
|
-
this.datafile = datafile;
|
|
1056
|
-
this.datafileEtag = null;
|
|
1057
|
-
if (!this.ready) {
|
|
1058
|
-
this.onDatafileReadable();
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
this.initTracker();
|
|
1062
|
-
if (changed) {
|
|
1063
|
-
this.emit("configUpdate", {
|
|
1064
|
-
publishedAt: datafile.publishedAt,
|
|
1065
|
-
reason: "manual"
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1643
|
+
applyDatafileBootstrap(this.s, this, datafile);
|
|
1068
1644
|
}
|
|
1069
1645
|
/** The datafile currently in memory, or null before init completes. */
|
|
1070
1646
|
getDatafile() {
|
|
1071
|
-
return this.datafile;
|
|
1647
|
+
return this.s.datafile;
|
|
1072
1648
|
}
|
|
1073
|
-
//
|
|
1074
|
-
// Evaluation
|
|
1075
|
-
//
|
|
1649
|
+
// ---- Evaluation ----
|
|
1076
1650
|
// Context is the THIRD POSITIONAL argument on every call (B3), matching
|
|
1077
1651
|
// @avsbhq/edge. `forUser(ctx)` is the primary server pattern and binds it
|
|
1078
1652
|
// once per request.
|
|
1079
|
-
// ---------------------------------------------------------------------------
|
|
1080
1653
|
getFlag(flagKey, defaultValue, context, options) {
|
|
1081
1654
|
return this.evaluateInternal(flagKey, defaultValue, context, options);
|
|
1082
1655
|
}
|
|
@@ -1110,60 +1683,7 @@ var AvsbServer = class {
|
|
|
1110
1683
|
* published API: its options bag carries package-internal fields.
|
|
1111
1684
|
*/
|
|
1112
1685
|
evaluateInternal(flagKey, defaultValue, context, options) {
|
|
1113
|
-
|
|
1114
|
-
this.warnOnce(
|
|
1115
|
-
"no-context",
|
|
1116
|
-
`getFlag("${flagKey}") was called without a context, so it cannot be evaluated and returns the default you passed. Bind one per request with server.forUser({ kind: "user", key: userId }), or pass it as the third argument: server.getFlag(key, default, { kind: "user", key: userId }).`
|
|
1117
|
-
);
|
|
1118
|
-
return (0, import_core8.notReadyFlag)(
|
|
1119
|
-
defaultValue,
|
|
1120
|
-
"no context passed; call server.forUser(context) or pass the context positionally"
|
|
1121
|
-
);
|
|
1122
|
-
}
|
|
1123
|
-
if (!this.isReady() || !this.datafile) {
|
|
1124
|
-
this.warnOnce(
|
|
1125
|
-
"not-ready",
|
|
1126
|
-
`getFlag("${flagKey}") was called before the datafile loaded, so it returns the default you passed and flag.source is "not_ready". Await server.onReady() during boot, or pass a bootstrap datafile to the constructor for an instant start.`
|
|
1127
|
-
);
|
|
1128
|
-
return (0, import_core8.notReadyFlag)(defaultValue, "SDK not ready: no datafile loaded yet");
|
|
1129
|
-
}
|
|
1130
|
-
const decide = this.resolveDecideOptions(options?.decideOptions);
|
|
1131
|
-
const flag = options?.forcedVariationKey ? this.buildForcedFlag(
|
|
1132
|
-
flagKey,
|
|
1133
|
-
options.forcedVariationKey,
|
|
1134
|
-
defaultValue,
|
|
1135
|
-
context,
|
|
1136
|
-
options
|
|
1137
|
-
) : this.evaluateAndBuild(
|
|
1138
|
-
flagKey,
|
|
1139
|
-
defaultValue,
|
|
1140
|
-
context,
|
|
1141
|
-
decide,
|
|
1142
|
-
options
|
|
1143
|
-
);
|
|
1144
|
-
return flag;
|
|
1145
|
-
}
|
|
1146
|
-
evaluateAndBuild(flagKey, defaultValue, context, decide, options) {
|
|
1147
|
-
const legacyCtx = this.buildLegacyContext(context, decide.suppressExposure);
|
|
1148
|
-
const result = this.evaluate(flagKey, legacyCtx, defaultValue);
|
|
1149
|
-
const typeMismatch = this.checkType(flagKey, options?.expectedType, result);
|
|
1150
|
-
if (typeMismatch) {
|
|
1151
|
-
this.logger.warn(
|
|
1152
|
-
`Type mismatch on flag "${flagKey}": ${typeMismatch}; returning the default.`
|
|
1153
|
-
);
|
|
1154
|
-
const fallback = (0, import_core8.notFoundFlag)(defaultValue, typeMismatch);
|
|
1155
|
-
this.recordDecision(flagKey, fallback, context, options);
|
|
1156
|
-
return fallback;
|
|
1157
|
-
}
|
|
1158
|
-
const flag = this.toFlag(result, defaultValue);
|
|
1159
|
-
this.recordDecision(flagKey, flag, context, options);
|
|
1160
|
-
return flag;
|
|
1161
|
-
}
|
|
1162
|
-
checkType(flagKey, expectedType, result) {
|
|
1163
|
-
if (!expectedType) return null;
|
|
1164
|
-
if (result.source === "not_found" || result.source === "disabled") return null;
|
|
1165
|
-
const datafileFlag = (0, import_core8.findDatafileFlag)(this.datafile, flagKey);
|
|
1166
|
-
return (0, import_core8.checkExpectedType)(datafileFlag, expectedType, result.value) ?? null;
|
|
1686
|
+
return evaluateServerFlag(this.s, this, flagKey, defaultValue, context, options);
|
|
1167
1687
|
}
|
|
1168
1688
|
/**
|
|
1169
1689
|
* Every flag in the environment, evaluated against one context.
|
|
@@ -1174,37 +1694,9 @@ var AvsbServer = class {
|
|
|
1174
1694
|
* arguments.
|
|
1175
1695
|
*/
|
|
1176
1696
|
getAllFlags(context, options) {
|
|
1177
|
-
|
|
1178
|
-
this.warnOnce(
|
|
1179
|
-
"get-all-no-context",
|
|
1180
|
-
"getAllFlags() was called without a context and returned an empty object. Pass the context positionally, or use server.forUser(context).getAllFlags()."
|
|
1181
|
-
);
|
|
1182
|
-
return {};
|
|
1183
|
-
}
|
|
1184
|
-
if (!this.isReady() || !this.datafile) {
|
|
1185
|
-
this.warnOnce(
|
|
1186
|
-
"get-all-not-ready",
|
|
1187
|
-
"getAllFlags() was called before the datafile loaded and returned an empty object, which is indistinguishable from a project with no flags. Await server.onReady() first."
|
|
1188
|
-
);
|
|
1189
|
-
return {};
|
|
1190
|
-
}
|
|
1191
|
-
const fireExposures = options?.fireExposures === true;
|
|
1192
|
-
const decideOptions = [...options?.decideOptions ?? []];
|
|
1193
|
-
if (!fireExposures && !decideOptions.includes("DISABLE_EXPOSURE")) {
|
|
1194
|
-
decideOptions.push("DISABLE_EXPOSURE");
|
|
1195
|
-
}
|
|
1196
|
-
const out = {};
|
|
1197
|
-
for (const entry of this.datafile.flags) {
|
|
1198
|
-
out[entry.key] = this.evaluateInternal(entry.key, void 0, context, {
|
|
1199
|
-
decideOptions,
|
|
1200
|
-
decisionLog: options?.decisionLog
|
|
1201
|
-
});
|
|
1202
|
-
}
|
|
1203
|
-
return out;
|
|
1697
|
+
return evaluateAllServerFlags(this.s, this, context, options);
|
|
1204
1698
|
}
|
|
1205
|
-
//
|
|
1206
|
-
// Tracking
|
|
1207
|
-
// ---------------------------------------------------------------------------
|
|
1699
|
+
// ---- Tracking ----
|
|
1208
1700
|
/**
|
|
1209
1701
|
* Queue a metric (conversion) event. Synchronous: it returns as soon as the
|
|
1210
1702
|
* event is queued, and the tracker batches it.
|
|
@@ -1214,30 +1706,7 @@ var AvsbServer = class {
|
|
|
1214
1706
|
* the server; see the README for why the two runtimes read differently here.
|
|
1215
1707
|
*/
|
|
1216
1708
|
track(eventKey, payload) {
|
|
1217
|
-
|
|
1218
|
-
this.warnOnce(
|
|
1219
|
-
"track-properties",
|
|
1220
|
-
`track("${eventKey}") was called with properties, which are NOT stored for metric events: the ingestion contract for a conversion keeps the event name, the numeric value, the timestamp, and the visitor id. Exposure events do carry properties. Nothing else about the call is affected.`
|
|
1221
|
-
);
|
|
1222
|
-
}
|
|
1223
|
-
if (!this.tracker) {
|
|
1224
|
-
this.enqueuePending(eventKey, payload);
|
|
1225
|
-
return;
|
|
1226
|
-
}
|
|
1227
|
-
this.tracker.queueMetric({
|
|
1228
|
-
visitorId: (0, import_core8.bucketingKey)(payload.context),
|
|
1229
|
-
eventName: eventKey,
|
|
1230
|
-
// TWO columns, not one. `revenue` is money in decimal major units;
|
|
1231
|
-
// `value` is the numeric metric value for average-value metrics. Storage
|
|
1232
|
-
// declares both (`metric_events.revenue`, `metric_events.value`). This
|
|
1233
|
-
// SDK used to ship `payload.value` into the revenue column and could
|
|
1234
|
-
// never write the value column at all, so a quantity was recorded as
|
|
1235
|
-
// money and every average-value metric read empty.
|
|
1236
|
-
revenue: payload.revenue ?? null,
|
|
1237
|
-
value: payload.value ?? null,
|
|
1238
|
-
timestamp: Date.now(),
|
|
1239
|
-
pageUrl: ""
|
|
1240
|
-
});
|
|
1709
|
+
trackMetric(this.s, eventKey, payload);
|
|
1241
1710
|
}
|
|
1242
1711
|
/**
|
|
1243
1712
|
* Send a purchase to `/v1/collect/orders`.
|
|
@@ -1245,20 +1714,8 @@ var AvsbServer = class {
|
|
|
1245
1714
|
* Queued before the tracker exists, exactly like `track` (they used to
|
|
1246
1715
|
* disagree: one queued, the other warned and dropped the order).
|
|
1247
1716
|
*/
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
if (!this.tracker) {
|
|
1251
|
-
if (this.pendingPurchases.length >= this.eventOptions.maxQueueSize) {
|
|
1252
|
-
this.pendingPurchases.shift();
|
|
1253
|
-
this.warnOnce(
|
|
1254
|
-
"pending-purchase-full",
|
|
1255
|
-
`Pre-ready purchase queue reached ${this.eventOptions.maxQueueSize} orders; the oldest is being dropped. This means the datafile never loaded: check the datafile error logged above.`
|
|
1256
|
-
);
|
|
1257
|
-
}
|
|
1258
|
-
this.pendingPurchases.push({ visitorId, order });
|
|
1259
|
-
return;
|
|
1260
|
-
}
|
|
1261
|
-
return this.tracker.trackPurchase(visitorId, order);
|
|
1717
|
+
trackPurchase(visitorId, order) {
|
|
1718
|
+
return trackPurchase(this.s, visitorId, order);
|
|
1262
1719
|
}
|
|
1263
1720
|
/**
|
|
1264
1721
|
* Mark the moment a signup or login claimed an anonymous session. Same shape
|
|
@@ -1279,8 +1736,8 @@ var AvsbServer = class {
|
|
|
1279
1736
|
* the README for the cross-surface version of the same rule.
|
|
1280
1737
|
*/
|
|
1281
1738
|
alias(previousContext, newContext) {
|
|
1282
|
-
this.logger.debug(
|
|
1283
|
-
`alias: ${(0,
|
|
1739
|
+
this.s.logger.debug(
|
|
1740
|
+
`alias: ${(0, import_core12.bucketingKey)(previousContext)} to ${(0, import_core12.bucketingKey)(newContext)}`
|
|
1284
1741
|
);
|
|
1285
1742
|
this.track("avsb_alias", { context: newContext });
|
|
1286
1743
|
}
|
|
@@ -1290,29 +1747,9 @@ var AvsbServer = class {
|
|
|
1290
1747
|
* now actually seen the result.
|
|
1291
1748
|
*/
|
|
1292
1749
|
manualExposure(flagKey, context) {
|
|
1293
|
-
|
|
1294
|
-
const legacyCtx = this.buildLegacyContext(context, true);
|
|
1295
|
-
const result = this.evaluate(flagKey, legacyCtx, void 0);
|
|
1296
|
-
if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
|
|
1297
|
-
if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
|
|
1298
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
1299
|
-
if (!flag) return;
|
|
1300
|
-
const variation = flag.variations.find((v) => v.key === result.variationKey);
|
|
1301
|
-
if (!variation) return;
|
|
1302
|
-
this.deliverExposure({
|
|
1303
|
-
visitorId: (0, import_core8.bucketingKey)(context),
|
|
1304
|
-
experimentId: result.ruleId,
|
|
1305
|
-
variationId: variation.id,
|
|
1306
|
-
eventName: "flag_exposure",
|
|
1307
|
-
revenue: null,
|
|
1308
|
-
timestamp: Date.now(),
|
|
1309
|
-
pageUrl: "",
|
|
1310
|
-
properties: { flagKey, flagId: flag.id, manuallyFired: true }
|
|
1311
|
-
});
|
|
1750
|
+
manualExposure(this.s, this, flagKey, context);
|
|
1312
1751
|
}
|
|
1313
|
-
//
|
|
1314
|
-
// Per-request scoping
|
|
1315
|
-
// ---------------------------------------------------------------------------
|
|
1752
|
+
// ---- Per-request scoping ----
|
|
1316
1753
|
/** A client with the context bound. The primary server pattern. */
|
|
1317
1754
|
forUser(context) {
|
|
1318
1755
|
return createUserBoundClient(this, context);
|
|
@@ -1321,437 +1758,32 @@ var AvsbServer = class {
|
|
|
1321
1758
|
createUserContext(context) {
|
|
1322
1759
|
return createUserBoundClient(this, context);
|
|
1323
1760
|
}
|
|
1324
|
-
//
|
|
1325
|
-
// Overrides
|
|
1326
|
-
// ---------------------------------------------------------------------------
|
|
1761
|
+
// ---- Overrides ----
|
|
1327
1762
|
setOverrideForUser(flagKey, contextKey, variationKey, contextKind = "user") {
|
|
1328
|
-
this.overrides.setForContext(flagKey, contextKey, variationKey, contextKind);
|
|
1763
|
+
this.s.overrides.setForContext(flagKey, contextKey, variationKey, contextKind);
|
|
1329
1764
|
}
|
|
1330
1765
|
setGlobalOverride(flagKey, variationKey) {
|
|
1331
|
-
this.overrides.setGlobal(flagKey, variationKey);
|
|
1766
|
+
this.s.overrides.setGlobal(flagKey, variationKey);
|
|
1332
1767
|
}
|
|
1333
1768
|
clearOverrideForUser(flagKey, contextKey, contextKind = "user") {
|
|
1334
|
-
this.overrides.clearForContext(flagKey, contextKey, contextKind);
|
|
1769
|
+
this.s.overrides.clearForContext(flagKey, contextKey, contextKind);
|
|
1335
1770
|
}
|
|
1336
1771
|
clearGlobalOverride(flagKey) {
|
|
1337
|
-
this.overrides.clearGlobal(flagKey);
|
|
1772
|
+
this.s.overrides.clearGlobal(flagKey);
|
|
1338
1773
|
}
|
|
1339
1774
|
clearAllOverrides() {
|
|
1340
|
-
this.overrides.clearAll();
|
|
1775
|
+
this.s.overrides.clearAll();
|
|
1341
1776
|
}
|
|
1342
1777
|
/** A fresh decision log to pass per call. `forUser` carries its own. */
|
|
1343
1778
|
createDecisionLog() {
|
|
1344
1779
|
return createDecisionLog();
|
|
1345
1780
|
}
|
|
1346
|
-
//
|
|
1347
|
-
// Events
|
|
1348
|
-
// ---------------------------------------------------------------------------
|
|
1781
|
+
// ---- Events ----
|
|
1349
1782
|
on(event, cb) {
|
|
1350
|
-
|
|
1351
|
-
this.listeners.get(event).add(cb);
|
|
1352
|
-
return () => {
|
|
1353
|
-
this.listeners.get(event)?.delete(cb);
|
|
1354
|
-
};
|
|
1783
|
+
return addListener(this.s, event, cb);
|
|
1355
1784
|
}
|
|
1356
1785
|
off(event, cb) {
|
|
1357
|
-
this.listeners.get(event)?.delete(cb);
|
|
1358
|
-
}
|
|
1359
|
-
/**
|
|
1360
|
-
* `on` for the shared `ClientEventMap` names the utils layer speaks. The
|
|
1361
|
-
* server emits every one of them except `flagChange`, which needs per-flag
|
|
1362
|
-
* diffing the server does not do; subscribing to it warns and hands back a
|
|
1363
|
-
* no-op disposer instead of pretending.
|
|
1364
|
-
*/
|
|
1365
|
-
onSharedEvent(event, cb) {
|
|
1366
|
-
if (event === "flagChange") {
|
|
1367
|
-
this.warnOnce(
|
|
1368
|
-
"no-flag-change",
|
|
1369
|
-
'The server SDK does not emit "flagChange": it holds one datafile for the whole process and does not diff individual flags. Subscribe to "configUpdate" for "a new datafile was published" instead.'
|
|
1370
|
-
);
|
|
1371
|
-
return () => void 0;
|
|
1372
|
-
}
|
|
1373
|
-
return this.on(
|
|
1374
|
-
event,
|
|
1375
|
-
cb
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
emit(event, ...args) {
|
|
1379
|
-
const cbs = this.listeners.get(event);
|
|
1380
|
-
if (!cbs) return;
|
|
1381
|
-
for (const cb of cbs) {
|
|
1382
|
-
try {
|
|
1383
|
-
;
|
|
1384
|
-
cb(...args);
|
|
1385
|
-
} catch (e) {
|
|
1386
|
-
this.logger.error("Listener error", { event, error: String(e) });
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
warnOnce(key, message) {
|
|
1391
|
-
if (this.warnedOnce.has(key)) return;
|
|
1392
|
-
this.warnedOnce.add(key);
|
|
1393
|
-
this.logger.warn(message);
|
|
1394
|
-
}
|
|
1395
|
-
// ---------------------------------------------------------------------------
|
|
1396
|
-
// Private — evaluation core
|
|
1397
|
-
// ---------------------------------------------------------------------------
|
|
1398
|
-
evaluate(flagKey, uc, defaultValue) {
|
|
1399
|
-
const reasons = [];
|
|
1400
|
-
const overrideKey = this.overrides.resolve(flagKey, uc.userId);
|
|
1401
|
-
const rtOver = overrideKey ? this.findVariation(flagKey, overrideKey) : null;
|
|
1402
|
-
if (rtOver) {
|
|
1403
|
-
reasons.push(`matched runtime override for flagKey="${flagKey}"`);
|
|
1404
|
-
return {
|
|
1405
|
-
value: rtOver.value,
|
|
1406
|
-
variationKey: rtOver.key,
|
|
1407
|
-
source: "runtimeOverride",
|
|
1408
|
-
ruleId: null,
|
|
1409
|
-
ruleType: null,
|
|
1410
|
-
reasons
|
|
1411
|
-
};
|
|
1412
|
-
}
|
|
1413
|
-
if (this.stickyBucketService) {
|
|
1414
|
-
const sticky = this.resolveStickyBucket(flagKey, uc.userId);
|
|
1415
|
-
if (sticky !== void 0) {
|
|
1416
|
-
reasons.push(`matched sticky bucket assignment for userId="${uc.userId}"`);
|
|
1417
|
-
if (!uc.suppressExposure) {
|
|
1418
|
-
this.fireExposureForStickyAssignment(flagKey, sticky, uc);
|
|
1419
|
-
}
|
|
1420
|
-
return {
|
|
1421
|
-
value: sticky.vref.value,
|
|
1422
|
-
variationKey: sticky.vref.key,
|
|
1423
|
-
source: "sticky",
|
|
1424
|
-
ruleId: sticky.assignment.ruleId,
|
|
1425
|
-
ruleType: sticky.assignment.ruleType,
|
|
1426
|
-
reasons
|
|
1427
|
-
};
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
|
|
1431
|
-
const onExposure = uc.suppressExposure ? void 0 : (event) => {
|
|
1432
|
-
this.deliverExposure((0, import_exposure.redactExposure)(event, uc.privateAttributes));
|
|
1433
|
-
};
|
|
1434
|
-
const r = (0, import_engine.evaluateFlag)(
|
|
1435
|
-
flagKey,
|
|
1436
|
-
defaultValue,
|
|
1437
|
-
legacyCtx,
|
|
1438
|
-
this.datafile,
|
|
1439
|
-
onExposure,
|
|
1440
|
-
reasons
|
|
1441
|
-
);
|
|
1442
|
-
if (this.stickyBucketService && r.variationKey && r.source === "rule" && r.ruleId && r.ruleType === "ab_test") {
|
|
1443
|
-
const v = this.findVariation(flagKey, r.variationKey);
|
|
1444
|
-
if (v) {
|
|
1445
|
-
this.stickyBucketService.save(uc.userId, flagKey, {
|
|
1446
|
-
variationId: v.id,
|
|
1447
|
-
ruleId: r.ruleId,
|
|
1448
|
-
ruleType: r.ruleType,
|
|
1449
|
-
assignedAt: Date.now()
|
|
1450
|
-
});
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
return {
|
|
1454
|
-
value: r.value,
|
|
1455
|
-
variationKey: r.variationKey,
|
|
1456
|
-
source: (0, import_core8.mapLegacySource)(r.source),
|
|
1457
|
-
ruleId: r.ruleId,
|
|
1458
|
-
ruleType: r.ruleType,
|
|
1459
|
-
reasons
|
|
1460
|
-
};
|
|
1461
|
-
}
|
|
1462
|
-
/**
|
|
1463
|
-
* One exposure, delivered everywhere: queued for ingestion AND emitted on
|
|
1464
|
-
* the event bus with the SAME `eventId`, so a customer forwarding decisions
|
|
1465
|
-
* to their own analytics can reconcile rows with A vs B results.
|
|
1466
|
-
*/
|
|
1467
|
-
deliverExposure(event) {
|
|
1468
|
-
const stamped = event.eventId ? event : { ...event, eventId: (0, import_node_crypto2.randomUUID)() };
|
|
1469
|
-
if (this.tracker) {
|
|
1470
|
-
this.tracker.queue(stamped);
|
|
1471
|
-
} else {
|
|
1472
|
-
this.enqueuePendingExposure(stamped);
|
|
1473
|
-
}
|
|
1474
|
-
this.emit("exposure", stamped);
|
|
1475
|
-
}
|
|
1476
|
-
toFlag(result, defaultValue) {
|
|
1477
|
-
return (0, import_core8.createFlag)({
|
|
1478
|
-
value: result.value ?? defaultValue,
|
|
1479
|
-
variationKey: result.variationKey,
|
|
1480
|
-
source: result.source,
|
|
1481
|
-
ruleId: result.ruleId,
|
|
1482
|
-
ruleType: result.ruleType,
|
|
1483
|
-
reasons: result.reasons,
|
|
1484
|
-
evaluatedAt: Date.now(),
|
|
1485
|
-
durationMicros: 0
|
|
1486
|
-
});
|
|
1487
|
-
}
|
|
1488
|
-
/** Build a Flag from a forced variation key (per-request forced decision). */
|
|
1489
|
-
buildForcedFlag(flagKey, variationKey, defaultValue, context, options) {
|
|
1490
|
-
const vref = this.findVariation(flagKey, variationKey);
|
|
1491
|
-
const flag = (0, import_core8.createFlag)({
|
|
1492
|
-
value: vref ? vref.value : defaultValue,
|
|
1493
|
-
variationKey: vref ? vref.key : null,
|
|
1494
|
-
source: "runtimeOverride",
|
|
1495
|
-
ruleId: null,
|
|
1496
|
-
ruleType: null,
|
|
1497
|
-
reasons: [`forced decision: variationKey="${variationKey}"`]
|
|
1498
|
-
});
|
|
1499
|
-
this.recordDecision(flagKey, flag, context, options);
|
|
1500
|
-
return flag;
|
|
1501
|
-
}
|
|
1502
|
-
/** Emit the evaluation event and append to a decision log if one was passed. */
|
|
1503
|
-
recordDecision(flagKey, flag, context, options) {
|
|
1504
|
-
const summary = (0, import_core8.contextSummary)(context);
|
|
1505
|
-
this.emit("evaluation", {
|
|
1506
|
-
flagKey,
|
|
1507
|
-
flag,
|
|
1508
|
-
contextKeys: summary.map((entry) => entry.key)
|
|
1509
|
-
});
|
|
1510
|
-
if (!options?.decisionLog) return;
|
|
1511
|
-
appendDecisionLogEntry(options.decisionLog, {
|
|
1512
|
-
flagKey,
|
|
1513
|
-
value: flag.value,
|
|
1514
|
-
variationKey: flag.variationKey,
|
|
1515
|
-
source: flag.source,
|
|
1516
|
-
ruleId: flag.ruleId,
|
|
1517
|
-
ruleType: flag.ruleType,
|
|
1518
|
-
reasons: flag.reasons,
|
|
1519
|
-
evaluatedAt: flag.evaluatedAt,
|
|
1520
|
-
durationMicros: flag.durationMicros,
|
|
1521
|
-
contextSummary: summary,
|
|
1522
|
-
privateValues: privateAttributeValues(context)
|
|
1523
|
-
});
|
|
1524
|
-
}
|
|
1525
|
-
buildLegacyContext(context, suppressExposure) {
|
|
1526
|
-
const legacy = (0, import_core8.toLegacyEvalContext)(context);
|
|
1527
|
-
const userId = legacy.attributes["userId"] ?? "";
|
|
1528
|
-
const privateAttrs = (0, import_core8.privateAttributesFrom)(context);
|
|
1529
|
-
const { userId: _uid, ...rest } = legacy.attributes;
|
|
1530
|
-
return {
|
|
1531
|
-
userId,
|
|
1532
|
-
attributes: rest,
|
|
1533
|
-
suppressExposure,
|
|
1534
|
-
privateAttributes: privateAttrs.size > 0 ? [...privateAttrs] : void 0
|
|
1535
|
-
};
|
|
1536
|
-
}
|
|
1537
|
-
// ---------------------------------------------------------------------------
|
|
1538
|
-
// Private — sticky helpers
|
|
1539
|
-
// ---------------------------------------------------------------------------
|
|
1540
|
-
resolveStickyBucket(flagKey, userId) {
|
|
1541
|
-
if (!this.stickyBucketService || !this.datafile) return void 0;
|
|
1542
|
-
const assignment = this.stickyBucketService.lookup(userId, flagKey);
|
|
1543
|
-
if (!assignment) return void 0;
|
|
1544
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
1545
|
-
if (!flag) return void 0;
|
|
1546
|
-
const v = flag.variations.find((x) => x.id === assignment.variationId);
|
|
1547
|
-
if (!v) return void 0;
|
|
1548
|
-
return { vref: { value: v.value, key: v.key, id: v.id }, assignment };
|
|
1549
|
-
}
|
|
1550
|
-
findVariation(flagKey, variationKey) {
|
|
1551
|
-
if (!this.datafile) return null;
|
|
1552
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
1553
|
-
if (!flag) return null;
|
|
1554
|
-
const v = flag.variations.find((x) => x.key === variationKey);
|
|
1555
|
-
if (!v) return null;
|
|
1556
|
-
return { value: v.value, key: v.key, id: v.id };
|
|
1557
|
-
}
|
|
1558
|
-
fireExposureForStickyAssignment(flagKey, sticky, uc) {
|
|
1559
|
-
if (!this.tracker || !this.datafile) return;
|
|
1560
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
1561
|
-
if (!flag) return;
|
|
1562
|
-
const rule = flag.rules.find((r) => r.id === sticky.assignment.ruleId);
|
|
1563
|
-
if (!rule || !rule.variations.some((rv) => rv.variationId === sticky.vref.id)) {
|
|
1564
|
-
this.logger.warn(
|
|
1565
|
-
"Sticky assignment references a rule no longer matching the datafile",
|
|
1566
|
-
{
|
|
1567
|
-
flagKey,
|
|
1568
|
-
userId: uc.userId,
|
|
1569
|
-
assignedRuleId: sticky.assignment.ruleId,
|
|
1570
|
-
assignedVariationId: sticky.vref.id
|
|
1571
|
-
}
|
|
1572
|
-
);
|
|
1573
|
-
this.emit("stickyOrphan", {
|
|
1574
|
-
flagKey,
|
|
1575
|
-
userId: uc.userId,
|
|
1576
|
-
assignment: sticky.assignment
|
|
1577
|
-
});
|
|
1578
|
-
return;
|
|
1579
|
-
}
|
|
1580
|
-
this.deliverExposure({
|
|
1581
|
-
visitorId: uc.userId,
|
|
1582
|
-
experimentId: rule.id,
|
|
1583
|
-
variationId: sticky.vref.id,
|
|
1584
|
-
eventName: "flag_exposure",
|
|
1585
|
-
revenue: null,
|
|
1586
|
-
timestamp: Date.now(),
|
|
1587
|
-
pageUrl: "",
|
|
1588
|
-
properties: { flagKey, flagId: flag.id }
|
|
1589
|
-
});
|
|
1590
|
-
}
|
|
1591
|
-
// ---------------------------------------------------------------------------
|
|
1592
|
-
// Private — tracker, polling, streaming, heartbeat
|
|
1593
|
-
// ---------------------------------------------------------------------------
|
|
1594
|
-
initTracker() {
|
|
1595
|
-
if (!this.datafile || this.tracker) return;
|
|
1596
|
-
const tracker = createServerTracker({
|
|
1597
|
-
sdkKey: this.sdkKey,
|
|
1598
|
-
collectEndpoint: this.datafile.collectEndpoint,
|
|
1599
|
-
flushInterval: this.eventOptions.flushInterval,
|
|
1600
|
-
batchSize: this.eventOptions.batchSize,
|
|
1601
|
-
maxQueueSize: this.eventOptions.maxQueueSize,
|
|
1602
|
-
retryAttempts: this.eventOptions.retryAttempts,
|
|
1603
|
-
logger: this.logger
|
|
1604
|
-
});
|
|
1605
|
-
this.tracker = tracker;
|
|
1606
|
-
if (this.pendingExposures.length > 0) {
|
|
1607
|
-
const queued = this.pendingExposures;
|
|
1608
|
-
this.pendingExposures = [];
|
|
1609
|
-
for (const event of queued) tracker.queue(event);
|
|
1610
|
-
}
|
|
1611
|
-
if (this.pendingEvents.length > 0) {
|
|
1612
|
-
const queued = this.pendingEvents;
|
|
1613
|
-
this.pendingEvents = [];
|
|
1614
|
-
for (const item of queued) this.track(item.eventKey, item.payload);
|
|
1615
|
-
}
|
|
1616
|
-
if (this.pendingPurchases.length > 0) {
|
|
1617
|
-
const queued = this.pendingPurchases;
|
|
1618
|
-
this.pendingPurchases = [];
|
|
1619
|
-
for (const item of queued) void this.trackPurchase(item.visitorId, item.order);
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
enqueuePendingExposure(event) {
|
|
1623
|
-
if (this.pendingExposures.length >= this.eventOptions.maxQueueSize) {
|
|
1624
|
-
this.pendingExposures.shift();
|
|
1625
|
-
this.warnOnce(
|
|
1626
|
-
"pending-exposures-full",
|
|
1627
|
-
`Pre-ready exposure queue reached ${this.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
|
|
1628
|
-
);
|
|
1629
|
-
}
|
|
1630
|
-
this.pendingExposures.push(event);
|
|
1631
|
-
}
|
|
1632
|
-
enqueuePending(eventKey, payload) {
|
|
1633
|
-
if (this.pendingEvents.length >= this.eventOptions.maxQueueSize) {
|
|
1634
|
-
this.pendingEvents.shift();
|
|
1635
|
-
this.warnOnce(
|
|
1636
|
-
"pending-events-full",
|
|
1637
|
-
`Pre-ready event queue reached ${this.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
|
|
1638
|
-
);
|
|
1639
|
-
}
|
|
1640
|
-
this.pendingEvents.push({ eventKey, payload });
|
|
1641
|
-
}
|
|
1642
|
-
/**
|
|
1643
|
-
* Fetch and apply the datafile.
|
|
1644
|
-
*
|
|
1645
|
-
* Three outcomes, not two, because a conditional request has three:
|
|
1646
|
-
* 'applied' a datafile came back and is now in memory
|
|
1647
|
-
* 'unchanged' the CDN answered 304: what we hold IS current
|
|
1648
|
-
* 'failed' nothing usable came back; the error is already logged
|
|
1649
|
-
*
|
|
1650
|
-
* 'unchanged' is a success. Collapsing it into 'failed' made
|
|
1651
|
-
* `refreshDatafile()` warn that a refresh "did not apply a new datafile"
|
|
1652
|
-
* every time the datafile was simply up to date.
|
|
1653
|
-
*/
|
|
1654
|
-
async loadDatafile(reason = "manual") {
|
|
1655
|
-
const outcome = await fetchDatafile({
|
|
1656
|
-
sdkKey: this.sdkKey,
|
|
1657
|
-
cdnHost: this.cdnHost,
|
|
1658
|
-
signal: this.abortController.signal,
|
|
1659
|
-
logger: this.logger,
|
|
1660
|
-
etag: this.datafileEtag
|
|
1661
|
-
});
|
|
1662
|
-
if (outcome.status === "ok" || outcome.status === "not-modified") {
|
|
1663
|
-
this.sendHeartbeat();
|
|
1664
|
-
}
|
|
1665
|
-
if (outcome.status === "not-modified") return "unchanged";
|
|
1666
|
-
if (outcome.status !== "ok") return "failed";
|
|
1667
|
-
const changed = !this.datafile || this.datafile.publishedAt !== outcome.datafile.publishedAt;
|
|
1668
|
-
this.datafile = outcome.datafile;
|
|
1669
|
-
this.datafileEtag = outcome.etag;
|
|
1670
|
-
if (this.ready && changed) {
|
|
1671
|
-
this.emit("configUpdate", {
|
|
1672
|
-
publishedAt: outcome.datafile.publishedAt,
|
|
1673
|
-
reason
|
|
1674
|
-
});
|
|
1675
|
-
this.logger.info("Datafile updated", {
|
|
1676
|
-
publishedAt: outcome.datafile.publishedAt,
|
|
1677
|
-
reason
|
|
1678
|
-
});
|
|
1679
|
-
this.startStreamingIfEnabled();
|
|
1680
|
-
}
|
|
1681
|
-
return "applied";
|
|
1682
|
-
}
|
|
1683
|
-
/**
|
|
1684
|
-
* Poll on a JITTERED interval. Without the jitter every process that
|
|
1685
|
-
* deployed together polls the CDN on the same tick forever.
|
|
1686
|
-
*/
|
|
1687
|
-
startPolling() {
|
|
1688
|
-
if (this.pollingTimer || this.destroyed) return;
|
|
1689
|
-
const schedule = () => {
|
|
1690
|
-
this.pollingTimer = setTimeout(() => {
|
|
1691
|
-
this.pollingTimer = null;
|
|
1692
|
-
void (async () => {
|
|
1693
|
-
const result = await this.loadDatafile("poll");
|
|
1694
|
-
if (result === "applied" && !this.ready) this.onDatafileReadable();
|
|
1695
|
-
if (!this.destroyed) schedule();
|
|
1696
|
-
})();
|
|
1697
|
-
}, (0, import_backoff2.jitteredInterval)(this.pollingInterval));
|
|
1698
|
-
};
|
|
1699
|
-
schedule();
|
|
1700
|
-
}
|
|
1701
|
-
/**
|
|
1702
|
-
* Open the SSE stream when one is available, and fall back to polling in
|
|
1703
|
-
* silence when it is not (B12). The endpoint comes from the datafile's
|
|
1704
|
-
* `streamEndpoint` field, or an explicit option; it is never guessed from
|
|
1705
|
-
* another host, which is what gave one port an infinite reconnect loop
|
|
1706
|
-
* against DNS that does not resolve.
|
|
1707
|
-
*/
|
|
1708
|
-
startStreamingIfEnabled() {
|
|
1709
|
-
if (!this.streamingEnabled || this.streamingClient || this.destroyed) return;
|
|
1710
|
-
const resolution = (0, import_core8.resolveStreamEndpoint)({
|
|
1711
|
-
optionEndpoint: this.streamingEndpointOption,
|
|
1712
|
-
datafileStreamEndpoint: this.datafile?.streamEndpoint ?? null
|
|
1713
|
-
});
|
|
1714
|
-
if (!resolution.available) {
|
|
1715
|
-
this.logger.debug(`Streaming stays off, polling continues. ${resolution.reason}`);
|
|
1716
|
-
return;
|
|
1717
|
-
}
|
|
1718
|
-
this.streamingClient = (0, import_streaming.createStreamingClient)({
|
|
1719
|
-
endpoint: resolution.endpoint,
|
|
1720
|
-
sdkKey: this.sdkKey,
|
|
1721
|
-
onDatafileUpdated: () => {
|
|
1722
|
-
void this.loadDatafile("stream");
|
|
1723
|
-
},
|
|
1724
|
-
logger: this.logger
|
|
1725
|
-
});
|
|
1726
|
-
this.streamingClient.connect();
|
|
1727
|
-
this.logger.debug(
|
|
1728
|
-
`Streaming datafile updates from ${resolution.endpoint} (${resolution.source}).`
|
|
1729
|
-
);
|
|
1730
|
-
}
|
|
1731
|
-
/**
|
|
1732
|
-
* Report liveness, throttled.
|
|
1733
|
-
*
|
|
1734
|
-
* Called on every successful datafile load, not just at init: the dashboard
|
|
1735
|
-
* marks an environment Stale after five minutes without a heartbeat, so an
|
|
1736
|
-
* init-only heartbeat made every long-running process look dead a few
|
|
1737
|
-
* minutes after it started. The throttle keeps a fast polling interval (or a
|
|
1738
|
-
* burst of stream-triggered refreshes) from turning liveness into traffic.
|
|
1739
|
-
*
|
|
1740
|
-
* Fire and forget: a failed heartbeat never affects evaluation, and the
|
|
1741
|
-
* throttle clock is stamped by the synchronous send below, so a hanging
|
|
1742
|
-
* request cannot queue a second one.
|
|
1743
|
-
*/
|
|
1744
|
-
sendHeartbeat() {
|
|
1745
|
-
const now = Date.now();
|
|
1746
|
-
if (now - this.lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
|
|
1747
|
-
const posted = sendHeartbeat({
|
|
1748
|
-
sdkKey: this.sdkKey,
|
|
1749
|
-
sdkType: SDK_TYPE,
|
|
1750
|
-
sdkVersion: SDK_VERSION,
|
|
1751
|
-
datafileHeartbeatEndpoint: this.datafile?.heartbeatEndpoint,
|
|
1752
|
-
signal: this.abortController.signal
|
|
1753
|
-
});
|
|
1754
|
-
if (posted) this.lastHeartbeatAt = now;
|
|
1786
|
+
this.s.listeners.get(event)?.delete(cb);
|
|
1755
1787
|
}
|
|
1756
1788
|
};
|
|
1757
1789
|
|
|
@@ -1789,7 +1821,7 @@ var RedisStickyBucketService = class {
|
|
|
1789
1821
|
this.prefix = options.prefix ?? "avsb:sticky:";
|
|
1790
1822
|
this.ttl = options.ttl ?? 2592e3;
|
|
1791
1823
|
this.maxCacheSize = options.cacheSize ?? 1e4;
|
|
1792
|
-
this.logger = options.logger ??
|
|
1824
|
+
this.logger = options.logger ?? import_core6.noopLogger;
|
|
1793
1825
|
}
|
|
1794
1826
|
redisKey(userId, flagKey) {
|
|
1795
1827
|
return `${this.prefix}${userId}:${flagKey}`;
|
|
@@ -1928,7 +1960,7 @@ function verifyWebhookSignature(payload, headers, secret, options = {}) {
|
|
|
1928
1960
|
}
|
|
1929
1961
|
|
|
1930
1962
|
// src/index.ts
|
|
1931
|
-
var
|
|
1963
|
+
var import_core13 = require("@avsbhq/core");
|
|
1932
1964
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1933
1965
|
0 && (module.exports = {
|
|
1934
1966
|
AvsbServer,
|