@avsbhq/node 1.6.2 → 1.7.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/LICENSE +21 -0
- package/README.md +576 -272
- package/dist/index.cjs +917 -524
- package/dist/index.d.cts +392 -135
- package/dist/index.d.ts +392 -135
- package/dist/index.js +891 -508
- package/package.json +33 -11
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,30 +17,47 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// src/index.ts
|
|
21
31
|
var index_exports = {};
|
|
22
32
|
__export(index_exports, {
|
|
23
33
|
AvsbServer: () => AvsbServer,
|
|
24
|
-
|
|
34
|
+
DEFAULT_TOLERANCE_SECONDS: () => DEFAULT_TOLERANCE_SECONDS,
|
|
35
|
+
DecideOption: () => import_core9.DecideOption,
|
|
25
36
|
InMemoryStickyBucketService: () => InMemoryStickyBucketService,
|
|
26
37
|
RedisStickyBucketService: () => RedisStickyBucketService,
|
|
27
38
|
SDK_VERSION: () => SDK_VERSION,
|
|
28
|
-
consoleLogger: () =>
|
|
29
|
-
consoleTransport: () =>
|
|
39
|
+
consoleLogger: () => import_core4.consoleLogger,
|
|
40
|
+
consoleTransport: () => import_core4.consoleTransport,
|
|
30
41
|
createDecisionLog: () => createDecisionLog,
|
|
31
|
-
createLogger: () =>
|
|
42
|
+
createLogger: () => import_core4.createLogger,
|
|
32
43
|
createStreamingClient: () => import_streaming.createStreamingClient,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
defaultLogger: () => defaultLogger,
|
|
45
|
+
expressMiddleware: () => import_express.expressMiddleware,
|
|
46
|
+
fastifyPlugin: () => import_fastify.fastifyPlugin,
|
|
47
|
+
isDevEnvironment: () => isDevEnvironment,
|
|
48
|
+
noopLogger: () => import_core4.noopLogger,
|
|
49
|
+
resolveServerLogger: () => resolveServerLogger,
|
|
50
|
+
verifyWebhookSignature: () => verifyWebhookSignature
|
|
36
51
|
});
|
|
37
52
|
module.exports = __toCommonJS(index_exports);
|
|
38
53
|
|
|
39
54
|
// src/AvsbServer.ts
|
|
55
|
+
var import_node_crypto2 = require("crypto");
|
|
40
56
|
var import_engine = require("@avsbhq/core/engine");
|
|
41
|
-
var
|
|
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");
|
|
42
61
|
|
|
43
62
|
// src/datasets.ts
|
|
44
63
|
var DEFAULT_REQUEST_TIMEOUT = 1e4;
|
|
@@ -292,42 +311,234 @@ function createRecsClient(options) {
|
|
|
292
311
|
}
|
|
293
312
|
|
|
294
313
|
// src/decisionLog.ts
|
|
314
|
+
var import_core = require("@avsbhq/core");
|
|
315
|
+
var REDACTED = "[REDACTED]";
|
|
316
|
+
var MIN_SUBSTRING_LENGTH = 2;
|
|
295
317
|
function createDecisionLog() {
|
|
296
318
|
const entries = [];
|
|
297
319
|
return { entries };
|
|
298
320
|
}
|
|
321
|
+
function privateAttributeValues(ctx) {
|
|
322
|
+
const out = /* @__PURE__ */ new Set();
|
|
323
|
+
const collect = (sub) => {
|
|
324
|
+
const names = sub._meta?.privateAttributes ?? [];
|
|
325
|
+
for (const name of names) {
|
|
326
|
+
const raw = sub[name];
|
|
327
|
+
if (raw === void 0 || raw === null) continue;
|
|
328
|
+
const text = typeof raw === "string" ? raw : stringify(raw);
|
|
329
|
+
if (text) out.add(text);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
if ((0, import_core.isMultiContext)(ctx)) {
|
|
333
|
+
for (const [kind, value] of Object.entries(ctx)) {
|
|
334
|
+
if (kind === "kind") continue;
|
|
335
|
+
if (!value || typeof value !== "object") continue;
|
|
336
|
+
collect(value);
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
collect(ctx);
|
|
340
|
+
}
|
|
341
|
+
return [...out].sort((a, b) => b.length - a.length);
|
|
342
|
+
}
|
|
343
|
+
function stringify(value) {
|
|
344
|
+
if (typeof value === "string") return value;
|
|
345
|
+
try {
|
|
346
|
+
return JSON.stringify(value) ?? "";
|
|
347
|
+
} catch {
|
|
348
|
+
return String(value);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function matches(haystack, needle) {
|
|
352
|
+
if (haystack === needle) return true;
|
|
353
|
+
if (needle.length < MIN_SUBSTRING_LENGTH) return false;
|
|
354
|
+
return haystack.includes(needle);
|
|
355
|
+
}
|
|
356
|
+
function redactValue(value, privateValues) {
|
|
357
|
+
if (privateValues.length === 0) return value;
|
|
358
|
+
if (value === void 0 || value === null) return value;
|
|
359
|
+
const serialised = stringify(value);
|
|
360
|
+
if (!serialised) return value;
|
|
361
|
+
for (const priv of privateValues) {
|
|
362
|
+
if (matches(serialised, priv)) return REDACTED;
|
|
363
|
+
}
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
function redactReasons(reasons, privateValues) {
|
|
367
|
+
if (privateValues.length === 0) return [...reasons];
|
|
368
|
+
return reasons.map((reason) => {
|
|
369
|
+
let out = reason;
|
|
370
|
+
for (const priv of privateValues) {
|
|
371
|
+
if (out === priv) return REDACTED;
|
|
372
|
+
if (priv.length < MIN_SUBSTRING_LENGTH) continue;
|
|
373
|
+
out = out.split(priv).join(REDACTED);
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function appendDecisionLogEntry(log, input) {
|
|
379
|
+
const entry = {
|
|
380
|
+
flagKey: input.flagKey,
|
|
381
|
+
value: redactValue(input.value, input.privateValues),
|
|
382
|
+
variationKey: input.variationKey,
|
|
383
|
+
source: input.source,
|
|
384
|
+
ruleId: input.ruleId,
|
|
385
|
+
ruleType: input.ruleType,
|
|
386
|
+
reasons: redactReasons(input.reasons, input.privateValues),
|
|
387
|
+
evaluatedAt: input.evaluatedAt,
|
|
388
|
+
durationMicros: input.durationMicros,
|
|
389
|
+
contextSummary: input.contextSummary,
|
|
390
|
+
...input.manuallyFired ? { manuallyFired: true } : {}
|
|
391
|
+
};
|
|
392
|
+
log.entries.push(entry);
|
|
393
|
+
return entry;
|
|
394
|
+
}
|
|
299
395
|
|
|
300
|
-
// src/
|
|
301
|
-
var
|
|
396
|
+
// src/decideOptions.ts
|
|
397
|
+
var import_core2 = require("@avsbhq/core");
|
|
398
|
+
var KNOWN = Object.values(import_core2.DecideOption);
|
|
399
|
+
function createDecideOptionResolver(logger) {
|
|
400
|
+
const warned = /* @__PURE__ */ new Set();
|
|
401
|
+
return function resolve(options) {
|
|
402
|
+
if (!options || options.length === 0) return { suppressExposure: false };
|
|
403
|
+
let suppressExposure = false;
|
|
404
|
+
for (const option of options) {
|
|
405
|
+
if (option === import_core2.DecideOption.DISABLE_EXPOSURE) {
|
|
406
|
+
suppressExposure = true;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (option === import_core2.DecideOption.INCLUDE_REASONS) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const unknown = String(option);
|
|
413
|
+
if (!warned.has(unknown)) {
|
|
414
|
+
warned.add(unknown);
|
|
415
|
+
logger.warn(
|
|
416
|
+
`Unknown decide option "${unknown}" ignored. Accepted values are ${KNOWN.join(", ")}. EXCLUDE_VARIABLES, IGNORE_STICKY_BUCKET and ENABLED_FLAGS_ONLY were deleted because no SDK ever implemented them.`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return { suppressExposure };
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/overrideStore.ts
|
|
425
|
+
function createOverrideStore() {
|
|
426
|
+
const byFlagKey = /* @__PURE__ */ new Map();
|
|
427
|
+
function ensure(flagKey) {
|
|
428
|
+
const existing = byFlagKey.get(flagKey);
|
|
429
|
+
if (existing) return existing;
|
|
430
|
+
const created = { perKindKey: /* @__PURE__ */ new Map() };
|
|
431
|
+
byFlagKey.set(flagKey, created);
|
|
432
|
+
return created;
|
|
433
|
+
}
|
|
434
|
+
function prune(flagKey, entry) {
|
|
435
|
+
if (!entry.global && entry.perKindKey.size === 0) byFlagKey.delete(flagKey);
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
resolve(flagKey, contextKey, contextKind = "user") {
|
|
439
|
+
const entry = byFlagKey.get(flagKey);
|
|
440
|
+
if (!entry) return void 0;
|
|
441
|
+
return entry.perKindKey.get(`${contextKind}:${contextKey}`) ?? entry.global;
|
|
442
|
+
},
|
|
443
|
+
setForContext(flagKey, contextKey, variationKey, contextKind = "user") {
|
|
444
|
+
ensure(flagKey).perKindKey.set(`${contextKind}:${contextKey}`, variationKey);
|
|
445
|
+
},
|
|
446
|
+
setGlobal(flagKey, variationKey) {
|
|
447
|
+
ensure(flagKey).global = variationKey;
|
|
448
|
+
},
|
|
449
|
+
clearForContext(flagKey, contextKey, contextKind = "user") {
|
|
450
|
+
const entry = byFlagKey.get(flagKey);
|
|
451
|
+
if (!entry) return;
|
|
452
|
+
entry.perKindKey.delete(`${contextKind}:${contextKey}`);
|
|
453
|
+
prune(flagKey, entry);
|
|
454
|
+
},
|
|
455
|
+
clearGlobal(flagKey) {
|
|
456
|
+
const entry = byFlagKey.get(flagKey);
|
|
457
|
+
if (!entry) return;
|
|
458
|
+
entry.global = void 0;
|
|
459
|
+
prune(flagKey, entry);
|
|
460
|
+
},
|
|
461
|
+
clearAll() {
|
|
462
|
+
byFlagKey.clear();
|
|
463
|
+
},
|
|
464
|
+
size() {
|
|
465
|
+
return byFlagKey.size;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/datafileSource.ts
|
|
471
|
+
var import_core3 = require("@avsbhq/core");
|
|
472
|
+
async function fetchDatafile(input) {
|
|
473
|
+
const url = (0, import_core3.datafileUrl)(input.sdkKey, input.cdnHost);
|
|
474
|
+
const headers = {};
|
|
475
|
+
if (input.etag) headers["If-None-Match"] = input.etag;
|
|
476
|
+
try {
|
|
477
|
+
const response = await fetch(url, { signal: input.signal, headers });
|
|
478
|
+
if (response.status === 304) return { status: "not-modified" };
|
|
479
|
+
if (!response.ok) {
|
|
480
|
+
input.logger.error(
|
|
481
|
+
`AvsB datafile request failed with HTTP ${response.status} for ${url}. ` + (response.status === 404 ? "That SDK key has no published datafile: check the key belongs to this environment and that the environment has been published at least once (app.avsb.cloud, then Settings, then Environments)." : "The SDK keeps serving whatever datafile it already has and retries on the polling interval.")
|
|
482
|
+
);
|
|
483
|
+
return { status: "failed" };
|
|
484
|
+
}
|
|
485
|
+
const datafile = await response.json();
|
|
486
|
+
return { status: "ok", datafile, etag: response.headers.get("etag") };
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (input.signal.aborted) return { status: "aborted" };
|
|
489
|
+
input.logger.error(
|
|
490
|
+
`AvsB datafile request to ${url} could not complete: ${String(error)}. This is a network, DNS, or TLS failure rather than a rejected key; the SDK retries on the polling interval.`
|
|
491
|
+
);
|
|
492
|
+
return { status: "failed" };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function sendHeartbeat(input) {
|
|
496
|
+
const endpoint = (0, import_core3.resolveHeartbeatEndpoint)(input.datafileHeartbeatEndpoint);
|
|
497
|
+
if (!endpoint) return null;
|
|
498
|
+
void fetch(endpoint, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: { "Content-Type": "application/json" },
|
|
501
|
+
body: JSON.stringify({
|
|
502
|
+
sdkKey: input.sdkKey,
|
|
503
|
+
sdkType: input.sdkType,
|
|
504
|
+
sdkVersion: input.sdkVersion
|
|
505
|
+
}),
|
|
506
|
+
signal: input.signal
|
|
507
|
+
}).catch(() => void 0);
|
|
508
|
+
return endpoint;
|
|
509
|
+
}
|
|
302
510
|
|
|
303
|
-
// src/
|
|
304
|
-
var
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
511
|
+
// src/logger.ts
|
|
512
|
+
var import_core4 = require("@avsbhq/core");
|
|
513
|
+
function readDevGlobal() {
|
|
514
|
+
const flag = globalThis.__AVSB_DEV__;
|
|
515
|
+
return typeof flag === "boolean" ? flag : null;
|
|
516
|
+
}
|
|
517
|
+
function readNodeEnv() {
|
|
518
|
+
const value = globalThis.process?.env?.NODE_ENV;
|
|
519
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
520
|
+
}
|
|
521
|
+
function isDevEnvironment() {
|
|
522
|
+
const explicit = readDevGlobal();
|
|
523
|
+
if (explicit !== null) return explicit;
|
|
524
|
+
const nodeEnv = readNodeEnv();
|
|
525
|
+
if (nodeEnv === null) return true;
|
|
526
|
+
return nodeEnv !== "production" && nodeEnv !== "test";
|
|
317
527
|
}
|
|
318
|
-
function
|
|
319
|
-
return
|
|
528
|
+
function defaultLogger() {
|
|
529
|
+
return isDevEnvironment() ? (0, import_core4.consoleLogger)("warn") : import_core4.noopLogger;
|
|
320
530
|
}
|
|
321
|
-
function
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
if (!Number.isFinite(n) || n < 0) return null;
|
|
327
|
-
return Math.round(n * 1e3);
|
|
531
|
+
function resolveServerLogger(options) {
|
|
532
|
+
if (options.logger) return options.logger;
|
|
533
|
+
if (options.logLevel === "silent") return import_core4.noopLogger;
|
|
534
|
+
if (options.logLevel) return (0, import_core4.consoleLogger)(options.logLevel);
|
|
535
|
+
return defaultLogger();
|
|
328
536
|
}
|
|
329
537
|
|
|
330
538
|
// src/serverTracker.ts
|
|
539
|
+
var import_node_crypto = require("crypto");
|
|
540
|
+
var import_core5 = require("@avsbhq/core");
|
|
541
|
+
var import_backoff = require("@avsbhq/utils/backoff");
|
|
331
542
|
function stampExposure(event) {
|
|
332
543
|
return event.eventId ? event : { ...event, eventId: (0, import_node_crypto.randomUUID)() };
|
|
333
544
|
}
|
|
@@ -344,9 +555,9 @@ function createServerTracker(options) {
|
|
|
344
555
|
retryAttempts,
|
|
345
556
|
logger
|
|
346
557
|
} = options;
|
|
347
|
-
const batchEndpoint =
|
|
348
|
-
const metricEndpoint =
|
|
349
|
-
const ordersEndpoint =
|
|
558
|
+
const batchEndpoint = (0, import_core5.collectUrl)("batch", collectEndpoint);
|
|
559
|
+
const metricEndpoint = (0, import_core5.collectUrl)("metricBatch", collectEndpoint);
|
|
560
|
+
const ordersEndpoint = (0, import_core5.collectUrl)("orders", collectEndpoint);
|
|
350
561
|
let exposureQueue = [];
|
|
351
562
|
let metricQueue = [];
|
|
352
563
|
let flushTimer = null;
|
|
@@ -403,7 +614,7 @@ function createServerTracker(options) {
|
|
|
403
614
|
}
|
|
404
615
|
async function sendBatch(endpoint, key, payload) {
|
|
405
616
|
const body = JSON.stringify({ sdkKey, [key]: payload });
|
|
406
|
-
let backoff = resetBackoff();
|
|
617
|
+
let backoff = (0, import_backoff.resetBackoff)();
|
|
407
618
|
let pendingRetryAfterMs = void 0;
|
|
408
619
|
for (let attempt = 0; attempt <= retryAttempts; attempt++) {
|
|
409
620
|
try {
|
|
@@ -414,7 +625,7 @@ function createServerTracker(options) {
|
|
|
414
625
|
signal: AbortSignal.timeout(1e4)
|
|
415
626
|
});
|
|
416
627
|
if (!response.ok) {
|
|
417
|
-
const parsed = parseRetryAfter(response.headers.get("retry-after"));
|
|
628
|
+
const parsed = (0, import_backoff.parseRetryAfter)(response.headers.get("retry-after"));
|
|
418
629
|
if (parsed !== null) pendingRetryAfterMs = parsed;
|
|
419
630
|
throw new Error(`HTTP ${response.status}`);
|
|
420
631
|
}
|
|
@@ -422,7 +633,7 @@ function createServerTracker(options) {
|
|
|
422
633
|
return;
|
|
423
634
|
} catch (e) {
|
|
424
635
|
if (attempt < retryAttempts) {
|
|
425
|
-
backoff = nextBackoff(backoff, pendingRetryAfterMs);
|
|
636
|
+
backoff = (0, import_backoff.nextBackoff)(backoff, pendingRetryAfterMs);
|
|
426
637
|
pendingRetryAfterMs = void 0;
|
|
427
638
|
await new Promise((r) => setTimeout(r, backoff.delayMs));
|
|
428
639
|
} else {
|
|
@@ -444,7 +655,7 @@ function createServerTracker(options) {
|
|
|
444
655
|
pageUrl: ""
|
|
445
656
|
};
|
|
446
657
|
const body = JSON.stringify({ sdkKey, orders: [payload] });
|
|
447
|
-
let backoff = resetBackoff();
|
|
658
|
+
let backoff = (0, import_backoff.resetBackoff)();
|
|
448
659
|
let pendingRetryAfterMs = void 0;
|
|
449
660
|
for (let attempt = 0; attempt <= retryAttempts; attempt++) {
|
|
450
661
|
try {
|
|
@@ -455,7 +666,7 @@ function createServerTracker(options) {
|
|
|
455
666
|
signal: AbortSignal.timeout(1e4)
|
|
456
667
|
});
|
|
457
668
|
if (!response.ok) {
|
|
458
|
-
const parsed = parseRetryAfter(response.headers.get("retry-after"));
|
|
669
|
+
const parsed = (0, import_backoff.parseRetryAfter)(response.headers.get("retry-after"));
|
|
459
670
|
if (parsed !== null) pendingRetryAfterMs = parsed;
|
|
460
671
|
throw new Error(`HTTP ${response.status}`);
|
|
461
672
|
}
|
|
@@ -463,7 +674,7 @@ function createServerTracker(options) {
|
|
|
463
674
|
return;
|
|
464
675
|
} catch (e) {
|
|
465
676
|
if (attempt < retryAttempts) {
|
|
466
|
-
backoff = nextBackoff(backoff, pendingRetryAfterMs);
|
|
677
|
+
backoff = (0, import_backoff.nextBackoff)(backoff, pendingRetryAfterMs);
|
|
467
678
|
pendingRetryAfterMs = void 0;
|
|
468
679
|
await new Promise((r) => setTimeout(r, backoff.delayMs));
|
|
469
680
|
} else {
|
|
@@ -490,76 +701,58 @@ function createServerTracker(options) {
|
|
|
490
701
|
var import_streaming = require("@avsbhq/utils/streaming");
|
|
491
702
|
|
|
492
703
|
// src/version.ts
|
|
493
|
-
var SDK_VERSION = "1.
|
|
704
|
+
var SDK_VERSION = "1.7.0";
|
|
494
705
|
|
|
495
706
|
// src/UserBoundClient.ts
|
|
707
|
+
var import_core6 = require("@avsbhq/core");
|
|
496
708
|
function createUserBoundClient(server, context) {
|
|
497
709
|
const decisionLog = createDecisionLog();
|
|
498
710
|
const forcedDecisions = /* @__PURE__ */ new Map();
|
|
499
|
-
function
|
|
500
|
-
|
|
501
|
-
|
|
711
|
+
function evaluate(flagKey, defaultValue, expectedType, options) {
|
|
712
|
+
const forced = forcedDecisions.get(flagKey);
|
|
713
|
+
return server.evaluateInternal(flagKey, defaultValue, context, {
|
|
502
714
|
decideOptions: options?.decideOptions,
|
|
503
|
-
|
|
504
|
-
|
|
715
|
+
// A caller may point one evaluation at their own log; the bound log is
|
|
716
|
+
// the default so `getDecisionLog()` is complete without extra wiring.
|
|
717
|
+
decisionLog: options?.decisionLog ?? decisionLog,
|
|
718
|
+
...expectedType ? { expectedType } : {},
|
|
719
|
+
...forced ? { forcedVariationKey: forced.variationKey } : {}
|
|
720
|
+
});
|
|
505
721
|
}
|
|
506
722
|
return {
|
|
507
723
|
getFlag(flagKey, defaultValue, options) {
|
|
508
|
-
|
|
509
|
-
if (forced) {
|
|
510
|
-
return server.getFlag(flagKey, defaultValue, {
|
|
511
|
-
...buildOptions(options),
|
|
512
|
-
forcedVariationKey: forced.variationKey
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
return server.getFlag(flagKey, defaultValue, buildOptions(options));
|
|
724
|
+
return evaluate(flagKey, defaultValue, void 0, options);
|
|
516
725
|
},
|
|
517
726
|
getBoolFlag(flagKey, defaultValue, options) {
|
|
518
|
-
|
|
519
|
-
if (forced) {
|
|
520
|
-
return server.getBoolFlag(flagKey, defaultValue, {
|
|
521
|
-
...buildOptions(options),
|
|
522
|
-
forcedVariationKey: forced.variationKey
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
return server.getBoolFlag(flagKey, defaultValue, buildOptions(options));
|
|
727
|
+
return evaluate(flagKey, defaultValue, "boolean", options);
|
|
526
728
|
},
|
|
527
729
|
getStringFlag(flagKey, defaultValue, options) {
|
|
528
|
-
|
|
529
|
-
if (forced) {
|
|
530
|
-
return server.getStringFlag(flagKey, defaultValue, {
|
|
531
|
-
...buildOptions(options),
|
|
532
|
-
forcedVariationKey: forced.variationKey
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
return server.getStringFlag(flagKey, defaultValue, buildOptions(options));
|
|
730
|
+
return evaluate(flagKey, defaultValue, "string", options);
|
|
536
731
|
},
|
|
537
732
|
getNumberFlag(flagKey, defaultValue, options) {
|
|
538
|
-
|
|
539
|
-
if (forced) {
|
|
540
|
-
return server.getNumberFlag(flagKey, defaultValue, {
|
|
541
|
-
...buildOptions(options),
|
|
542
|
-
forcedVariationKey: forced.variationKey
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
return server.getNumberFlag(flagKey, defaultValue, buildOptions(options));
|
|
733
|
+
return evaluate(flagKey, defaultValue, "number", options);
|
|
546
734
|
},
|
|
547
735
|
getJsonFlag(flagKey, defaultValue, options) {
|
|
548
|
-
|
|
549
|
-
if (forced) {
|
|
550
|
-
return server.getJsonFlag(flagKey, defaultValue, {
|
|
551
|
-
...buildOptions(options),
|
|
552
|
-
forcedVariationKey: forced.variationKey
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
return server.getJsonFlag(flagKey, defaultValue, buildOptions(options));
|
|
736
|
+
return evaluate(flagKey, defaultValue, "json", options);
|
|
556
737
|
},
|
|
557
738
|
getAllFlags(options) {
|
|
558
|
-
return server.getAllFlags(context,
|
|
739
|
+
return server.getAllFlags(context, {
|
|
740
|
+
...options,
|
|
741
|
+
decisionLog: options?.decisionLog ?? decisionLog
|
|
742
|
+
});
|
|
559
743
|
},
|
|
560
744
|
track(eventKey, payload) {
|
|
561
745
|
server.track(eventKey, { ...payload, context });
|
|
562
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
|
+
},
|
|
563
756
|
setForcedDecision(flagKey, opts) {
|
|
564
757
|
forcedDecisions.set(flagKey, opts);
|
|
565
758
|
},
|
|
@@ -574,11 +767,14 @@ function createUserBoundClient(server, context) {
|
|
|
574
767
|
|
|
575
768
|
// src/utils.ts
|
|
576
769
|
var import_utils = require("@avsbhq/utils");
|
|
577
|
-
var
|
|
770
|
+
var import_core7 = require("@avsbhq/core");
|
|
578
771
|
function buildServerUtils(server) {
|
|
579
772
|
const adapter = {
|
|
580
773
|
getFlag(flagKey, defaultValue) {
|
|
581
|
-
return (0,
|
|
774
|
+
return (0, import_core7.notFoundFlag)(
|
|
775
|
+
defaultValue,
|
|
776
|
+
"no bound context: call server.forUser(context) and use its flag methods"
|
|
777
|
+
);
|
|
582
778
|
},
|
|
583
779
|
getAllFlags(opts) {
|
|
584
780
|
const ctx = opts?.context;
|
|
@@ -599,10 +795,18 @@ function buildServerUtils(server) {
|
|
|
599
795
|
},
|
|
600
796
|
getDatafile: () => server.getDatafile(),
|
|
601
797
|
getCurrentContext: () => null,
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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.
|
|
606
810
|
};
|
|
607
811
|
return (0, import_utils.createUtils)(adapter);
|
|
608
812
|
}
|
|
@@ -614,63 +818,57 @@ var DEFAULT_EVENTS = {
|
|
|
614
818
|
maxQueueSize: 1e4,
|
|
615
819
|
retryAttempts: 3
|
|
616
820
|
};
|
|
617
|
-
var
|
|
821
|
+
var SDK_TYPE = "@avsbhq/node";
|
|
822
|
+
var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
|
|
618
823
|
var AvsbServer = class {
|
|
619
824
|
sdkKey;
|
|
620
|
-
pollingInterval;
|
|
621
825
|
cdnHost;
|
|
826
|
+
pollingInterval;
|
|
622
827
|
initTimeout;
|
|
623
828
|
logger;
|
|
624
829
|
stickyBucketService;
|
|
625
830
|
eventOptions;
|
|
626
831
|
streamingEnabled;
|
|
627
|
-
|
|
832
|
+
streamingEndpointOption;
|
|
833
|
+
overrides = createOverrideStore();
|
|
834
|
+
resolveDecideOptions;
|
|
628
835
|
datafile;
|
|
836
|
+
datafileEtag = null;
|
|
629
837
|
ready;
|
|
630
838
|
destroyed;
|
|
631
839
|
tracker;
|
|
632
840
|
streamingClient;
|
|
633
|
-
|
|
841
|
+
pollingTimer;
|
|
634
842
|
abortController = new AbortController();
|
|
635
|
-
overrides = /* @__PURE__ */ new Map();
|
|
636
843
|
listeners = /* @__PURE__ */ new Map();
|
|
637
844
|
readyPromise = null;
|
|
638
845
|
pendingEvents = [];
|
|
846
|
+
pendingPurchases = [];
|
|
847
|
+
/** ms-epoch of the last liveness heartbeat, for the throttle. */
|
|
848
|
+
lastHeartbeatAt = 0;
|
|
849
|
+
/** Keys of one-time warnings, so a request loop cannot flood a log. */
|
|
850
|
+
warnedOnce = /* @__PURE__ */ new Set();
|
|
639
851
|
/**
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
* The server's event surface (`ServerEventMap`) is a subset of the
|
|
644
|
-
* shared `ClientEventMap`; the utils-adapter `on` here ignores events
|
|
645
|
-
* the server doesn't emit (e.g. `flagChange`). Workstream 5 (CoW 6)
|
|
646
|
-
* harmonises the two maps when the streaming evaluator lands.
|
|
852
|
+
* `client.utils.*` surface, constructed lazily on first access so the utils
|
|
853
|
+
* package is only imported when actually consumed.
|
|
647
854
|
*/
|
|
648
855
|
_utils = null;
|
|
649
856
|
get utils() {
|
|
650
857
|
if (!this._utils) {
|
|
651
858
|
this._utils = buildServerUtils({
|
|
652
859
|
getAllFlags: (ctx, opts) => this.getAllFlags(ctx, opts),
|
|
653
|
-
getFlag: (flagKey, defaultValue,
|
|
860
|
+
getFlag: (flagKey, defaultValue, context) => this.getFlag(flagKey, defaultValue, context),
|
|
654
861
|
track: (eventKey, payload) => this.track(eventKey, payload),
|
|
655
|
-
on: (event, cb) =>
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
event,
|
|
659
|
-
cb
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
return () => void 0;
|
|
663
|
-
},
|
|
664
|
-
getDatafile: () => this.datafile
|
|
862
|
+
on: (event, cb) => this.onSharedEvent(event, cb),
|
|
863
|
+
getDatafile: () => this.datafile,
|
|
864
|
+
applyDatafileBootstrap: (df) => this.applyDatafileBootstrap(df)
|
|
665
865
|
});
|
|
666
866
|
}
|
|
667
867
|
return this._utils;
|
|
668
868
|
}
|
|
669
869
|
/**
|
|
670
|
-
* Server-side datasets read surface (
|
|
671
|
-
*
|
|
672
|
-
* `client.datasets.get(slug, key)` resolves the live version from the
|
|
673
|
-
* current datafile and never throws; see ./datasets.ts.
|
|
870
|
+
* Server-side datasets read surface. `client.datasets.get(slug, key)`
|
|
871
|
+
* resolves the live version from the current datafile and never throws.
|
|
674
872
|
*/
|
|
675
873
|
_datasets = null;
|
|
676
874
|
get datasets() {
|
|
@@ -684,11 +882,10 @@ var AvsbServer = class {
|
|
|
684
882
|
return this._datasets;
|
|
685
883
|
}
|
|
686
884
|
/**
|
|
687
|
-
* Server-side recommendations surface (
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
* tracker's queue and revenue attaches via the existing `trackPurchase`.
|
|
885
|
+
* Server-side recommendations surface. `client.recs.get({ recipe, … })`
|
|
886
|
+
* resolves the recipe registry from the current datafile and never throws;
|
|
887
|
+
* impressions and clicks ride the server tracker's queue and revenue
|
|
888
|
+
* attaches via `trackPurchase`.
|
|
692
889
|
*/
|
|
693
890
|
_recs = null;
|
|
694
891
|
get recs() {
|
|
@@ -705,75 +902,96 @@ var AvsbServer = class {
|
|
|
705
902
|
constructor(options) {
|
|
706
903
|
this.sdkKey = options.sdkKey;
|
|
707
904
|
this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
|
|
708
|
-
|
|
709
|
-
this.cdnHost = rawCdnHost.endsWith("/") ? rawCdnHost.slice(0, -1) : rawCdnHost;
|
|
905
|
+
this.cdnHost = options.cdnHost;
|
|
710
906
|
this.initTimeout = options.initTimeout ?? 1e4;
|
|
711
|
-
this.logger = options
|
|
907
|
+
this.logger = resolveServerLogger(options);
|
|
712
908
|
this.stickyBucketService = options.stickyBucketService ?? null;
|
|
713
909
|
this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
|
|
714
|
-
this.streamingEnabled = options.streaming
|
|
715
|
-
this.
|
|
910
|
+
this.streamingEnabled = options.streaming !== false;
|
|
911
|
+
this.streamingEndpointOption = options.streamingEndpoint ?? null;
|
|
716
912
|
this.datafile = options.bootstrap ?? null;
|
|
717
913
|
this.ready = !!options.bootstrap;
|
|
718
914
|
this.destroyed = false;
|
|
719
915
|
this.tracker = null;
|
|
720
916
|
this.streamingClient = null;
|
|
721
|
-
this.
|
|
917
|
+
this.pollingTimer = null;
|
|
918
|
+
this.resolveDecideOptions = createDecideOptionResolver(this.logger);
|
|
919
|
+
const keyShape = (0, import_sdkKey.validateSdkKeyShape)(this.sdkKey);
|
|
920
|
+
if (!keyShape.valid && keyShape.message) {
|
|
921
|
+
this.logger.error(keyShape.message);
|
|
922
|
+
}
|
|
722
923
|
}
|
|
723
924
|
// ---------------------------------------------------------------------------
|
|
724
|
-
//
|
|
925
|
+
// Readiness
|
|
725
926
|
// ---------------------------------------------------------------------------
|
|
927
|
+
/**
|
|
928
|
+
* Await the init attempt. Never rejects; always resolves to an InitResult.
|
|
929
|
+
* Calling it twice returns the same promise.
|
|
930
|
+
*/
|
|
726
931
|
onReady() {
|
|
727
932
|
if (this.readyPromise) return this.readyPromise;
|
|
728
933
|
this.readyPromise = this.initializeAndReady();
|
|
729
934
|
return this.readyPromise;
|
|
730
935
|
}
|
|
936
|
+
/** True when a datafile is loaded and flags are answerable right now. */
|
|
937
|
+
isReady() {
|
|
938
|
+
return this.ready && this.datafile !== null;
|
|
939
|
+
}
|
|
731
940
|
async initializeAndReady() {
|
|
732
941
|
if (this.ready && this.datafile) {
|
|
733
|
-
this.
|
|
734
|
-
this.startPolling();
|
|
735
|
-
this.startStreamingIfEnabled();
|
|
736
|
-
this.sendHeartbeat();
|
|
737
|
-
this.emit("ready");
|
|
942
|
+
this.onDatafileReadable();
|
|
738
943
|
return { success: true, source: "bootstrap" };
|
|
739
944
|
}
|
|
740
945
|
try {
|
|
741
|
-
const
|
|
742
|
-
|
|
946
|
+
const outcome = await Promise.race([
|
|
947
|
+
// At init there is no stored etag, so 'unchanged' cannot happen here;
|
|
948
|
+
// it is folded in anyway so a future caching layer cannot make init
|
|
949
|
+
// silently report failure.
|
|
950
|
+
this.loadDatafile().then((result) => ({
|
|
951
|
+
applied: result !== "failed",
|
|
952
|
+
timedOut: false
|
|
953
|
+
})),
|
|
743
954
|
new Promise(
|
|
744
|
-
(resolve) => setTimeout(
|
|
955
|
+
(resolve) => setTimeout(
|
|
956
|
+
() => resolve({ applied: false, timedOut: true }),
|
|
957
|
+
this.initTimeout
|
|
958
|
+
)
|
|
745
959
|
)
|
|
746
960
|
]);
|
|
747
|
-
if (
|
|
961
|
+
if (outcome.timedOut) {
|
|
748
962
|
this.startPolling();
|
|
749
|
-
|
|
750
|
-
|
|
963
|
+
const error2 = new Error(
|
|
964
|
+
`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.`
|
|
965
|
+
);
|
|
751
966
|
this.emit("error", error2);
|
|
752
967
|
return { success: false, source: "timeout", error: error2 };
|
|
753
968
|
}
|
|
754
|
-
if (
|
|
755
|
-
this.
|
|
756
|
-
this.initTracker();
|
|
757
|
-
this.ready = true;
|
|
758
|
-
this.startPolling();
|
|
759
|
-
this.startStreamingIfEnabled();
|
|
760
|
-
this.sendHeartbeat();
|
|
761
|
-
this.emit("ready");
|
|
969
|
+
if (outcome.applied) {
|
|
970
|
+
this.onDatafileReadable();
|
|
762
971
|
return { success: true, source: "network" };
|
|
763
972
|
}
|
|
764
973
|
this.startPolling();
|
|
765
|
-
|
|
766
|
-
|
|
974
|
+
const error = new Error(
|
|
975
|
+
'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.'
|
|
976
|
+
);
|
|
767
977
|
this.emit("error", error);
|
|
768
978
|
return { success: false, source: "error", error };
|
|
769
979
|
} catch (e) {
|
|
770
980
|
this.startPolling();
|
|
771
|
-
this.startStreamingIfEnabled();
|
|
772
981
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
773
982
|
this.emit("error", error);
|
|
774
983
|
return { success: false, source: "error", error };
|
|
775
984
|
}
|
|
776
985
|
}
|
|
986
|
+
/** Wiring that runs once a datafile is readable, however it arrived. */
|
|
987
|
+
onDatafileReadable() {
|
|
988
|
+
this.initTracker();
|
|
989
|
+
this.ready = true;
|
|
990
|
+
this.startPolling();
|
|
991
|
+
this.startStreamingIfEnabled();
|
|
992
|
+
this.sendHeartbeat();
|
|
993
|
+
this.emit("ready");
|
|
994
|
+
}
|
|
777
995
|
// ---------------------------------------------------------------------------
|
|
778
996
|
// Lifecycle
|
|
779
997
|
// ---------------------------------------------------------------------------
|
|
@@ -784,9 +1002,9 @@ var AvsbServer = class {
|
|
|
784
1002
|
this.streamingClient.disconnect();
|
|
785
1003
|
this.streamingClient = null;
|
|
786
1004
|
}
|
|
787
|
-
if (this.
|
|
788
|
-
|
|
789
|
-
this.
|
|
1005
|
+
if (this.pollingTimer) {
|
|
1006
|
+
clearTimeout(this.pollingTimer);
|
|
1007
|
+
this.pollingTimer = null;
|
|
790
1008
|
}
|
|
791
1009
|
if (this.tracker) {
|
|
792
1010
|
await this.tracker.destroy();
|
|
@@ -799,127 +1017,271 @@ var AvsbServer = class {
|
|
|
799
1017
|
async flush() {
|
|
800
1018
|
if (this.tracker) await this.tracker.flush();
|
|
801
1019
|
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Fetch the datafile now and apply it if it changed.
|
|
1022
|
+
*
|
|
1023
|
+
* A 304 is not a problem and is not warned about: it means the datafile in
|
|
1024
|
+
* memory IS the published one.
|
|
1025
|
+
*/
|
|
802
1026
|
async refreshDatafile() {
|
|
803
|
-
const
|
|
804
|
-
if (
|
|
805
|
-
this.logger.warn(
|
|
806
|
-
|
|
1027
|
+
const result = await this.loadDatafile("manual");
|
|
1028
|
+
if (result === "failed") {
|
|
1029
|
+
this.logger.warn(
|
|
1030
|
+
"AvsB datafile refresh did not apply a new datafile; the SDK keeps serving the one it already has. The request error is logged above."
|
|
1031
|
+
);
|
|
807
1032
|
}
|
|
808
|
-
|
|
809
|
-
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Adopt a datafile received out of band: a bootstrap blob from
|
|
1036
|
+
* `client.utils.hydrateBootstrap`, a file on disk, a KV read at cold start.
|
|
1037
|
+
*
|
|
1038
|
+
* Marks the SDK ready, drains anything queued before it, and starts polling
|
|
1039
|
+
* and streaming exactly as a network arrival would. This used to be a no-op
|
|
1040
|
+
* comment inside the utils adapter, which made the documented bootstrap
|
|
1041
|
+
* handoff silently do nothing.
|
|
1042
|
+
*/
|
|
1043
|
+
applyDatafileBootstrap(datafile) {
|
|
1044
|
+
if (this.destroyed) return;
|
|
1045
|
+
const changed = this.datafile?.publishedAt !== datafile.publishedAt;
|
|
1046
|
+
this.datafile = datafile;
|
|
1047
|
+
this.datafileEtag = null;
|
|
810
1048
|
if (!this.ready) {
|
|
811
|
-
this.
|
|
812
|
-
|
|
813
|
-
this.emit("ready");
|
|
814
|
-
} else if (changed) {
|
|
815
|
-
this.emit("configUpdate", { publishedAt: df.publishedAt });
|
|
816
|
-
this.logger.info("Datafile updated", { publishedAt: df.publishedAt });
|
|
1049
|
+
this.onDatafileReadable();
|
|
1050
|
+
return;
|
|
817
1051
|
}
|
|
1052
|
+
if (changed) {
|
|
1053
|
+
this.emit("configUpdate", {
|
|
1054
|
+
publishedAt: datafile.publishedAt,
|
|
1055
|
+
reason: "manual"
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
/** The datafile currently in memory, or null before init completes. */
|
|
1060
|
+
getDatafile() {
|
|
1061
|
+
return this.datafile;
|
|
818
1062
|
}
|
|
819
1063
|
// ---------------------------------------------------------------------------
|
|
820
|
-
//
|
|
1064
|
+
// Evaluation
|
|
1065
|
+
//
|
|
1066
|
+
// Context is the THIRD POSITIONAL argument on every call (B3), matching
|
|
1067
|
+
// @avsbhq/edge. `forUser(ctx)` is the primary server pattern and binds it
|
|
1068
|
+
// once per request.
|
|
821
1069
|
// ---------------------------------------------------------------------------
|
|
822
|
-
getFlag(flagKey, defaultValue, options) {
|
|
823
|
-
|
|
824
|
-
|
|
1070
|
+
getFlag(flagKey, defaultValue, context, options) {
|
|
1071
|
+
return this.evaluateInternal(flagKey, defaultValue, context, options);
|
|
1072
|
+
}
|
|
1073
|
+
getBoolFlag(flagKey, defaultValue, context, options) {
|
|
1074
|
+
return this.evaluateInternal(flagKey, defaultValue, context, {
|
|
1075
|
+
...options,
|
|
1076
|
+
expectedType: "boolean"
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
getStringFlag(flagKey, defaultValue, context, options) {
|
|
1080
|
+
return this.evaluateInternal(flagKey, defaultValue, context, {
|
|
1081
|
+
...options,
|
|
1082
|
+
expectedType: "string"
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
getNumberFlag(flagKey, defaultValue, context, options) {
|
|
1086
|
+
return this.evaluateInternal(flagKey, defaultValue, context, {
|
|
1087
|
+
...options,
|
|
1088
|
+
expectedType: "number"
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
getJsonFlag(flagKey, defaultValue, context, options) {
|
|
1092
|
+
return this.evaluateInternal(flagKey, defaultValue, context, {
|
|
1093
|
+
...options,
|
|
1094
|
+
expectedType: "json"
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* @internal Single evaluation entry point for this package. `UserBoundClient`
|
|
1099
|
+
* calls it with a bound context and forced decisions. Not part of the
|
|
1100
|
+
* published API: its options bag carries package-internal fields.
|
|
1101
|
+
*/
|
|
1102
|
+
evaluateInternal(flagKey, defaultValue, context, options) {
|
|
825
1103
|
if (!context) {
|
|
826
|
-
this.
|
|
827
|
-
|
|
1104
|
+
this.warnOnce(
|
|
1105
|
+
"no-context",
|
|
1106
|
+
`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 }).`
|
|
828
1107
|
);
|
|
829
|
-
return (0,
|
|
830
|
-
}
|
|
831
|
-
if (!this.ready || !this.datafile) {
|
|
832
|
-
this.logger.debug(`SDK not ready, returning default for ${flagKey}`);
|
|
833
|
-
return (0, import_core2.notFoundFlag)(defaultValue, "SDK not ready");
|
|
834
|
-
}
|
|
835
|
-
if (options?.forcedVariationKey) {
|
|
836
|
-
return this.buildFlagFromVariationKey(
|
|
837
|
-
flagKey,
|
|
838
|
-
options.forcedVariationKey,
|
|
1108
|
+
return (0, import_core8.notReadyFlag)(
|
|
839
1109
|
defaultValue,
|
|
840
|
-
"
|
|
841
|
-
context,
|
|
842
|
-
decisionLog
|
|
1110
|
+
"no context passed; call server.forUser(context) or pass the context positionally"
|
|
843
1111
|
);
|
|
844
1112
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1113
|
+
if (!this.isReady() || !this.datafile) {
|
|
1114
|
+
this.warnOnce(
|
|
1115
|
+
"not-ready",
|
|
1116
|
+
`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.`
|
|
1117
|
+
);
|
|
1118
|
+
return (0, import_core8.notReadyFlag)(defaultValue, "SDK not ready: no datafile loaded yet");
|
|
1119
|
+
}
|
|
1120
|
+
const decide = this.resolveDecideOptions(options?.decideOptions);
|
|
1121
|
+
const flag = options?.forcedVariationKey ? this.buildForcedFlag(
|
|
1122
|
+
flagKey,
|
|
1123
|
+
options.forcedVariationKey,
|
|
1124
|
+
defaultValue,
|
|
1125
|
+
context,
|
|
1126
|
+
options
|
|
1127
|
+
) : this.evaluateAndBuild(
|
|
1128
|
+
flagKey,
|
|
1129
|
+
defaultValue,
|
|
1130
|
+
context,
|
|
1131
|
+
decide,
|
|
1132
|
+
options
|
|
1133
|
+
);
|
|
1134
|
+
return flag;
|
|
858
1135
|
}
|
|
859
|
-
|
|
860
|
-
|
|
1136
|
+
evaluateAndBuild(flagKey, defaultValue, context, decide, options) {
|
|
1137
|
+
const legacyCtx = this.buildLegacyContext(context, decide.suppressExposure);
|
|
1138
|
+
const result = this.evaluate(flagKey, legacyCtx, defaultValue);
|
|
1139
|
+
const typeMismatch = this.checkType(flagKey, options?.expectedType, result);
|
|
1140
|
+
if (typeMismatch) {
|
|
1141
|
+
this.logger.warn(
|
|
1142
|
+
`Type mismatch on flag "${flagKey}": ${typeMismatch}; returning the default.`
|
|
1143
|
+
);
|
|
1144
|
+
const fallback = (0, import_core8.notFoundFlag)(defaultValue, typeMismatch);
|
|
1145
|
+
this.recordDecision(flagKey, fallback, context, options);
|
|
1146
|
+
return fallback;
|
|
1147
|
+
}
|
|
1148
|
+
const flag = this.toFlag(result, defaultValue);
|
|
1149
|
+
this.recordDecision(flagKey, flag, context, options);
|
|
1150
|
+
return flag;
|
|
861
1151
|
}
|
|
862
|
-
|
|
863
|
-
|
|
1152
|
+
checkType(flagKey, expectedType, result) {
|
|
1153
|
+
if (!expectedType) return null;
|
|
1154
|
+
if (result.source === "not_found" || result.source === "disabled") return null;
|
|
1155
|
+
const datafileFlag = (0, import_core8.findDatafileFlag)(this.datafile, flagKey);
|
|
1156
|
+
return (0, import_core8.checkExpectedType)(datafileFlag, expectedType, result.value) ?? null;
|
|
864
1157
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1158
|
+
/**
|
|
1159
|
+
* Every flag in the environment, evaluated against one context.
|
|
1160
|
+
*
|
|
1161
|
+
* Positional context (B3). Note the deliberate difference from
|
|
1162
|
+
* `@avsbhq/browser`, which keeps an options bag (`getAllFlags({context})`)
|
|
1163
|
+
* because it is context-bound and the bag disambiguates its optional
|
|
1164
|
+
* arguments.
|
|
1165
|
+
*/
|
|
868
1166
|
getAllFlags(context, options) {
|
|
869
|
-
if (!
|
|
1167
|
+
if (!context) {
|
|
1168
|
+
this.warnOnce(
|
|
1169
|
+
"get-all-no-context",
|
|
1170
|
+
"getAllFlags() was called without a context and returned an empty object. Pass the context positionally, or use server.forUser(context).getAllFlags()."
|
|
1171
|
+
);
|
|
1172
|
+
return {};
|
|
1173
|
+
}
|
|
1174
|
+
if (!this.isReady() || !this.datafile) {
|
|
1175
|
+
this.warnOnce(
|
|
1176
|
+
"get-all-not-ready",
|
|
1177
|
+
"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."
|
|
1178
|
+
);
|
|
1179
|
+
return {};
|
|
1180
|
+
}
|
|
870
1181
|
const fireExposures = options?.fireExposures === true;
|
|
1182
|
+
const decideOptions = [...options?.decideOptions ?? []];
|
|
1183
|
+
if (!fireExposures && !decideOptions.includes("DISABLE_EXPOSURE")) {
|
|
1184
|
+
decideOptions.push("DISABLE_EXPOSURE");
|
|
1185
|
+
}
|
|
871
1186
|
const out = {};
|
|
872
|
-
for (const
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
};
|
|
877
|
-
if (!fireExposures) {
|
|
878
|
-
const suppressCtx = this.buildLegacyContext(context, flagOptions);
|
|
879
|
-
suppressCtx.suppressExposure = true;
|
|
880
|
-
const result = this.evaluate(flagEntry.key, suppressCtx, void 0);
|
|
881
|
-
out[flagEntry.key] = this.toFlagNoLog(result, void 0, flagEntry.key);
|
|
882
|
-
} else {
|
|
883
|
-
out[flagEntry.key] = this.getFlag(flagEntry.key, void 0, flagOptions);
|
|
884
|
-
}
|
|
1187
|
+
for (const entry of this.datafile.flags) {
|
|
1188
|
+
out[entry.key] = this.evaluateInternal(entry.key, void 0, context, {
|
|
1189
|
+
decideOptions,
|
|
1190
|
+
decisionLog: options?.decisionLog
|
|
1191
|
+
});
|
|
885
1192
|
}
|
|
886
1193
|
return out;
|
|
887
1194
|
}
|
|
888
1195
|
// ---------------------------------------------------------------------------
|
|
889
|
-
//
|
|
890
|
-
// ---------------------------------------------------------------------------
|
|
891
|
-
async trackPurchase(visitorId, order) {
|
|
892
|
-
if (!this.tracker) {
|
|
893
|
-
this.logger.warn("trackPurchase called before tracker is ready; order dropped");
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
|
-
return this.tracker.trackPurchase(visitorId, order);
|
|
897
|
-
}
|
|
898
|
-
// ---------------------------------------------------------------------------
|
|
899
|
-
// §5.6 Unified track — context required
|
|
1196
|
+
// Tracking
|
|
900
1197
|
// ---------------------------------------------------------------------------
|
|
1198
|
+
/**
|
|
1199
|
+
* Queue a metric (conversion) event. Synchronous: it returns as soon as the
|
|
1200
|
+
* event is queued, and the tracker batches it.
|
|
1201
|
+
*
|
|
1202
|
+
* The context rides in the payload rather than positionally because it is
|
|
1203
|
+
* OPTIONAL vocabulary on the shared `TrackPayload` type and required only on
|
|
1204
|
+
* the server; see the README for why the two runtimes read differently here.
|
|
1205
|
+
*/
|
|
901
1206
|
track(eventKey, payload) {
|
|
902
|
-
|
|
903
|
-
|
|
1207
|
+
if (payload.properties) {
|
|
1208
|
+
this.warnOnce(
|
|
1209
|
+
"track-properties",
|
|
1210
|
+
`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.`
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
904
1213
|
if (!this.tracker) {
|
|
905
|
-
this.
|
|
1214
|
+
this.enqueuePending(eventKey, payload);
|
|
906
1215
|
return;
|
|
907
1216
|
}
|
|
908
1217
|
this.tracker.queueMetric({
|
|
909
|
-
visitorId:
|
|
1218
|
+
visitorId: (0, import_core8.bucketingKey)(payload.context),
|
|
910
1219
|
eventName: eventKey,
|
|
911
|
-
|
|
1220
|
+
// TWO columns, not one. `revenue` is money in decimal major units;
|
|
1221
|
+
// `value` is the numeric metric value for average-value metrics. Storage
|
|
1222
|
+
// declares both (`metric_events.revenue`, `metric_events.value`). This
|
|
1223
|
+
// SDK used to ship `payload.value` into the revenue column and could
|
|
1224
|
+
// never write the value column at all, so a quantity was recorded as
|
|
1225
|
+
// money and every average-value metric read empty.
|
|
1226
|
+
revenue: payload.revenue ?? null,
|
|
1227
|
+
value: payload.value ?? null,
|
|
912
1228
|
timestamp: Date.now(),
|
|
913
|
-
pageUrl: ""
|
|
914
|
-
...payload.properties ? { properties: payload.properties } : {}
|
|
1229
|
+
pageUrl: ""
|
|
915
1230
|
});
|
|
916
1231
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1232
|
+
/**
|
|
1233
|
+
* Send a purchase to `/v1/collect/orders`.
|
|
1234
|
+
*
|
|
1235
|
+
* Queued before the tracker exists, exactly like `track` (they used to
|
|
1236
|
+
* disagree: one queued, the other warned and dropped the order).
|
|
1237
|
+
*/
|
|
1238
|
+
async trackPurchase(visitorId, order) {
|
|
1239
|
+
if (this.destroyed) return;
|
|
1240
|
+
if (!this.tracker) {
|
|
1241
|
+
if (this.pendingPurchases.length >= this.eventOptions.maxQueueSize) {
|
|
1242
|
+
this.pendingPurchases.shift();
|
|
1243
|
+
this.warnOnce(
|
|
1244
|
+
"pending-purchase-full",
|
|
1245
|
+
`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.`
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
this.pendingPurchases.push({ visitorId, order });
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
return this.tracker.trackPurchase(visitorId, order);
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Mark the moment a signup or login claimed an anonymous session. Same shape
|
|
1255
|
+
* and same honesty as the browser client's `alias`.
|
|
1256
|
+
*
|
|
1257
|
+
* WHAT IT DOES: queues one `avsb_alias` event attributed to the NEW
|
|
1258
|
+
* identity's key.
|
|
1259
|
+
*
|
|
1260
|
+
* WHAT IT DOES NOT DO, precisely:
|
|
1261
|
+
* - It does not rewrite sticky assignments or move past exposures. Nothing
|
|
1262
|
+
* already decided for either identity changes.
|
|
1263
|
+
* - It does not store the PREVIOUS key. The conversion wire
|
|
1264
|
+
* (`/v1/collect/metric-batch`) carries no properties column, so a
|
|
1265
|
+
* retroactive session join is not available yet.
|
|
1266
|
+
*
|
|
1267
|
+
* To keep one visitor's bucketing across a login, evaluate with the SAME
|
|
1268
|
+
* context key on both sides. See "Sharing identity with the web snippet" in
|
|
1269
|
+
* the README for the cross-surface version of the same rule.
|
|
1270
|
+
*/
|
|
1271
|
+
alias(previousContext, newContext) {
|
|
1272
|
+
this.logger.debug(
|
|
1273
|
+
`alias: ${(0, import_core8.bucketingKey)(previousContext)} to ${(0, import_core8.bucketingKey)(newContext)}`
|
|
1274
|
+
);
|
|
1275
|
+
this.track("avsb_alias", { context: newContext });
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Record an exposure for a flag without re-serving its value. Use it when
|
|
1279
|
+
* the decision was read earlier with exposures suppressed and the visitor has
|
|
1280
|
+
* now actually seen the result.
|
|
1281
|
+
*/
|
|
920
1282
|
manualExposure(flagKey, context) {
|
|
921
|
-
if (!this.
|
|
922
|
-
const legacyCtx = this.buildLegacyContext(context,
|
|
1283
|
+
if (!this.isReady() || !this.datafile || !this.tracker) return;
|
|
1284
|
+
const legacyCtx = this.buildLegacyContext(context, true);
|
|
923
1285
|
const result = this.evaluate(flagKey, legacyCtx, void 0);
|
|
924
1286
|
if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
|
|
925
1287
|
if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
|
|
@@ -927,9 +1289,8 @@ var AvsbServer = class {
|
|
|
927
1289
|
if (!flag) return;
|
|
928
1290
|
const variation = flag.variations.find((v) => v.key === result.variationKey);
|
|
929
1291
|
if (!variation) return;
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
visitorId: userId,
|
|
1292
|
+
this.deliverExposure({
|
|
1293
|
+
visitorId: (0, import_core8.bucketingKey)(context),
|
|
933
1294
|
experimentId: result.ruleId,
|
|
934
1295
|
variationId: variation.id,
|
|
935
1296
|
eventName: "flag_exposure",
|
|
@@ -940,57 +1301,40 @@ var AvsbServer = class {
|
|
|
940
1301
|
});
|
|
941
1302
|
}
|
|
942
1303
|
// ---------------------------------------------------------------------------
|
|
943
|
-
//
|
|
1304
|
+
// Per-request scoping
|
|
944
1305
|
// ---------------------------------------------------------------------------
|
|
1306
|
+
/** A client with the context bound. The primary server pattern. */
|
|
945
1307
|
forUser(context) {
|
|
946
1308
|
return createUserBoundClient(this, context);
|
|
947
1309
|
}
|
|
1310
|
+
/** Ergonomic alias for `forUser`. */
|
|
948
1311
|
createUserContext(context) {
|
|
949
1312
|
return createUserBoundClient(this, context);
|
|
950
1313
|
}
|
|
951
1314
|
// ---------------------------------------------------------------------------
|
|
952
|
-
//
|
|
1315
|
+
// Overrides
|
|
953
1316
|
// ---------------------------------------------------------------------------
|
|
954
1317
|
setOverrideForUser(flagKey, contextKey, variationKey, contextKind = "user") {
|
|
955
|
-
|
|
956
|
-
this.overrides.set(flagKey, { perKindKey: /* @__PURE__ */ new Map() });
|
|
957
|
-
}
|
|
958
|
-
const entry = this.overrides.get(flagKey);
|
|
959
|
-
entry.perKindKey.set(`${contextKind}:${contextKey}`, variationKey);
|
|
1318
|
+
this.overrides.setForContext(flagKey, contextKey, variationKey, contextKind);
|
|
960
1319
|
}
|
|
961
1320
|
setGlobalOverride(flagKey, variationKey) {
|
|
962
|
-
|
|
963
|
-
this.overrides.set(flagKey, { perKindKey: /* @__PURE__ */ new Map() });
|
|
964
|
-
}
|
|
965
|
-
this.overrides.get(flagKey).global = variationKey;
|
|
1321
|
+
this.overrides.setGlobal(flagKey, variationKey);
|
|
966
1322
|
}
|
|
967
1323
|
clearOverrideForUser(flagKey, contextKey, contextKind = "user") {
|
|
968
|
-
|
|
969
|
-
if (!entry) return;
|
|
970
|
-
entry.perKindKey.delete(`${contextKind}:${contextKey}`);
|
|
971
|
-
if (!entry.global && entry.perKindKey.size === 0) {
|
|
972
|
-
this.overrides.delete(flagKey);
|
|
973
|
-
}
|
|
1324
|
+
this.overrides.clearForContext(flagKey, contextKey, contextKind);
|
|
974
1325
|
}
|
|
975
1326
|
clearGlobalOverride(flagKey) {
|
|
976
|
-
|
|
977
|
-
if (!entry) return;
|
|
978
|
-
entry.global = void 0;
|
|
979
|
-
if (entry.perKindKey.size === 0) {
|
|
980
|
-
this.overrides.delete(flagKey);
|
|
981
|
-
}
|
|
1327
|
+
this.overrides.clearGlobal(flagKey);
|
|
982
1328
|
}
|
|
983
1329
|
clearAllOverrides() {
|
|
984
|
-
this.overrides.
|
|
1330
|
+
this.overrides.clearAll();
|
|
985
1331
|
}
|
|
986
|
-
|
|
987
|
-
// Decision log factory (convenience)
|
|
988
|
-
// ---------------------------------------------------------------------------
|
|
1332
|
+
/** A fresh decision log to pass per call. `forUser` carries its own. */
|
|
989
1333
|
createDecisionLog() {
|
|
990
1334
|
return createDecisionLog();
|
|
991
1335
|
}
|
|
992
1336
|
// ---------------------------------------------------------------------------
|
|
993
|
-
//
|
|
1337
|
+
// Events
|
|
994
1338
|
// ---------------------------------------------------------------------------
|
|
995
1339
|
on(event, cb) {
|
|
996
1340
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
@@ -1002,13 +1346,50 @@ var AvsbServer = class {
|
|
|
1002
1346
|
off(event, cb) {
|
|
1003
1347
|
this.listeners.get(event)?.delete(cb);
|
|
1004
1348
|
}
|
|
1349
|
+
/**
|
|
1350
|
+
* `on` for the shared `ClientEventMap` names the utils layer speaks. The
|
|
1351
|
+
* server emits every one of them except `flagChange`, which needs per-flag
|
|
1352
|
+
* diffing the server does not do; subscribing to it warns and hands back a
|
|
1353
|
+
* no-op disposer instead of pretending.
|
|
1354
|
+
*/
|
|
1355
|
+
onSharedEvent(event, cb) {
|
|
1356
|
+
if (event === "flagChange") {
|
|
1357
|
+
this.warnOnce(
|
|
1358
|
+
"no-flag-change",
|
|
1359
|
+
'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.'
|
|
1360
|
+
);
|
|
1361
|
+
return () => void 0;
|
|
1362
|
+
}
|
|
1363
|
+
return this.on(
|
|
1364
|
+
event,
|
|
1365
|
+
cb
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
emit(event, ...args) {
|
|
1369
|
+
const cbs = this.listeners.get(event);
|
|
1370
|
+
if (!cbs) return;
|
|
1371
|
+
for (const cb of cbs) {
|
|
1372
|
+
try {
|
|
1373
|
+
;
|
|
1374
|
+
cb(...args);
|
|
1375
|
+
} catch (e) {
|
|
1376
|
+
this.logger.error("Listener error", { event, error: String(e) });
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
warnOnce(key, message) {
|
|
1381
|
+
if (this.warnedOnce.has(key)) return;
|
|
1382
|
+
this.warnedOnce.add(key);
|
|
1383
|
+
this.logger.warn(message);
|
|
1384
|
+
}
|
|
1005
1385
|
// ---------------------------------------------------------------------------
|
|
1006
|
-
// Private — core
|
|
1386
|
+
// Private — evaluation core
|
|
1007
1387
|
// ---------------------------------------------------------------------------
|
|
1008
1388
|
evaluate(flagKey, uc, defaultValue) {
|
|
1009
1389
|
const reasons = [];
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1390
|
+
const overrideKey = this.overrides.resolve(flagKey, uc.userId);
|
|
1391
|
+
const rtOver = overrideKey ? this.findVariation(flagKey, overrideKey) : null;
|
|
1392
|
+
if (rtOver) {
|
|
1012
1393
|
reasons.push(`matched runtime override for flagKey="${flagKey}"`);
|
|
1013
1394
|
return {
|
|
1014
1395
|
value: rtOver.value,
|
|
@@ -1037,15 +1418,9 @@ var AvsbServer = class {
|
|
|
1037
1418
|
}
|
|
1038
1419
|
}
|
|
1039
1420
|
const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
const cleaned = { ...event.properties };
|
|
1044
|
-
for (const k of uc.privateAttributes) delete cleaned[k];
|
|
1045
|
-
event = { ...event, properties: cleaned };
|
|
1046
|
-
}
|
|
1047
|
-
this.tracker?.queue(event);
|
|
1048
|
-
} : void 0;
|
|
1421
|
+
const onExposure = uc.suppressExposure ? void 0 : (event) => {
|
|
1422
|
+
this.deliverExposure((0, import_exposure.redactExposure)(event, uc.privateAttributes));
|
|
1423
|
+
};
|
|
1049
1424
|
const r = (0, import_engine.evaluateFlag)(
|
|
1050
1425
|
flagKey,
|
|
1051
1426
|
defaultValue,
|
|
@@ -1068,68 +1443,24 @@ var AvsbServer = class {
|
|
|
1068
1443
|
return {
|
|
1069
1444
|
value: r.value,
|
|
1070
1445
|
variationKey: r.variationKey,
|
|
1071
|
-
source: (0,
|
|
1446
|
+
source: (0, import_core8.mapLegacySource)(r.source),
|
|
1072
1447
|
ruleId: r.ruleId,
|
|
1073
1448
|
ruleType: r.ruleType,
|
|
1074
1449
|
reasons
|
|
1075
1450
|
};
|
|
1076
1451
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
const mismatch = (0, import_core2.checkExpectedType)(datafileFlag, expectedType, flag.value);
|
|
1087
|
-
if (mismatch) {
|
|
1088
|
-
this.logger.warn(
|
|
1089
|
-
`Type mismatch on flag "${flagKey}": ${mismatch}; returning default`
|
|
1090
|
-
);
|
|
1091
|
-
return (0, import_core2.notFoundFlag)(defaultValue, mismatch);
|
|
1092
|
-
}
|
|
1093
|
-
return flag;
|
|
1094
|
-
}
|
|
1095
|
-
// ---------------------------------------------------------------------------
|
|
1096
|
-
// Private — build Flag<T> from an EvalResult
|
|
1097
|
-
// ---------------------------------------------------------------------------
|
|
1098
|
-
toFlag(result, defaultValue, flagKey, context, decisionLog, startMicros) {
|
|
1099
|
-
const evaluatedAt = Date.now();
|
|
1100
|
-
const durationMicros = startMicros !== void 0 ? performance.now() * 1e3 - startMicros : 0;
|
|
1101
|
-
const flag = (0, import_core2.createFlag)({
|
|
1102
|
-
value: result.value ?? defaultValue,
|
|
1103
|
-
variationKey: result.variationKey,
|
|
1104
|
-
source: result.source,
|
|
1105
|
-
ruleId: result.ruleId,
|
|
1106
|
-
ruleType: result.ruleType,
|
|
1107
|
-
reasons: result.reasons,
|
|
1108
|
-
evaluatedAt,
|
|
1109
|
-
durationMicros: Math.round(durationMicros)
|
|
1110
|
-
});
|
|
1111
|
-
if (decisionLog) {
|
|
1112
|
-
const privateAttrs = (0, import_core2.privateAttributesFrom)(context);
|
|
1113
|
-
const summary = (0, import_core2.contextSummary)(context);
|
|
1114
|
-
const redactedValue = privateAttrs.size > 0 && typeof result.value === "string" && privateAttrs.has(result.value) ? "[REDACTED]" : result.value;
|
|
1115
|
-
decisionLog.entries.push({
|
|
1116
|
-
flagKey,
|
|
1117
|
-
value: redactedValue,
|
|
1118
|
-
variationKey: result.variationKey,
|
|
1119
|
-
source: result.source,
|
|
1120
|
-
ruleId: result.ruleId,
|
|
1121
|
-
ruleType: result.ruleType,
|
|
1122
|
-
reasons: [...result.reasons],
|
|
1123
|
-
evaluatedAt,
|
|
1124
|
-
durationMicros: Math.round(durationMicros),
|
|
1125
|
-
contextSummary: summary
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
return flag;
|
|
1452
|
+
/**
|
|
1453
|
+
* One exposure, delivered everywhere: queued for ingestion AND emitted on
|
|
1454
|
+
* the event bus with the SAME `eventId`, so a customer forwarding decisions
|
|
1455
|
+
* to their own analytics can reconcile rows with A vs B results.
|
|
1456
|
+
*/
|
|
1457
|
+
deliverExposure(event) {
|
|
1458
|
+
const stamped = event.eventId ? event : { ...event, eventId: (0, import_node_crypto2.randomUUID)() };
|
|
1459
|
+
this.tracker?.queue(stamped);
|
|
1460
|
+
this.emit("exposure", stamped);
|
|
1129
1461
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
return (0, import_core2.createFlag)({
|
|
1462
|
+
toFlag(result, defaultValue) {
|
|
1463
|
+
return (0, import_core8.createFlag)({
|
|
1133
1464
|
value: result.value ?? defaultValue,
|
|
1134
1465
|
variationKey: result.variationKey,
|
|
1135
1466
|
source: result.source,
|
|
@@ -1140,28 +1471,47 @@ var AvsbServer = class {
|
|
|
1140
1471
|
durationMicros: 0
|
|
1141
1472
|
});
|
|
1142
1473
|
}
|
|
1143
|
-
/** Build a Flag
|
|
1144
|
-
|
|
1474
|
+
/** Build a Flag from a forced variation key (per-request forced decision). */
|
|
1475
|
+
buildForcedFlag(flagKey, variationKey, defaultValue, context, options) {
|
|
1145
1476
|
const vref = this.findVariation(flagKey, variationKey);
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
value,
|
|
1477
|
+
const flag = (0, import_core8.createFlag)({
|
|
1478
|
+
value: vref ? vref.value : defaultValue,
|
|
1149
1479
|
variationKey: vref ? vref.key : null,
|
|
1150
|
-
source,
|
|
1480
|
+
source: "runtimeOverride",
|
|
1151
1481
|
ruleId: null,
|
|
1152
1482
|
ruleType: null,
|
|
1153
1483
|
reasons: [`forced decision: variationKey="${variationKey}"`]
|
|
1154
|
-
};
|
|
1155
|
-
|
|
1484
|
+
});
|
|
1485
|
+
this.recordDecision(flagKey, flag, context, options);
|
|
1486
|
+
return flag;
|
|
1156
1487
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1488
|
+
/** Emit the evaluation event and append to a decision log if one was passed. */
|
|
1489
|
+
recordDecision(flagKey, flag, context, options) {
|
|
1490
|
+
const summary = (0, import_core8.contextSummary)(context);
|
|
1491
|
+
this.emit("evaluation", {
|
|
1492
|
+
flagKey,
|
|
1493
|
+
flag,
|
|
1494
|
+
contextKeys: summary.map((entry) => entry.key)
|
|
1495
|
+
});
|
|
1496
|
+
if (!options?.decisionLog) return;
|
|
1497
|
+
appendDecisionLogEntry(options.decisionLog, {
|
|
1498
|
+
flagKey,
|
|
1499
|
+
value: flag.value,
|
|
1500
|
+
variationKey: flag.variationKey,
|
|
1501
|
+
source: flag.source,
|
|
1502
|
+
ruleId: flag.ruleId,
|
|
1503
|
+
ruleType: flag.ruleType,
|
|
1504
|
+
reasons: flag.reasons,
|
|
1505
|
+
evaluatedAt: flag.evaluatedAt,
|
|
1506
|
+
durationMicros: flag.durationMicros,
|
|
1507
|
+
contextSummary: summary,
|
|
1508
|
+
privateValues: privateAttributeValues(context)
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
buildLegacyContext(context, suppressExposure) {
|
|
1512
|
+
const legacy = (0, import_core8.toLegacyEvalContext)(context);
|
|
1162
1513
|
const userId = legacy.attributes["userId"] ?? "";
|
|
1163
|
-
const privateAttrs = (0,
|
|
1164
|
-
const suppressExposure = options?.suppressExposure === true || options?.decideOptions?.includes("DISABLE_EXPOSURE") === true;
|
|
1514
|
+
const privateAttrs = (0, import_core8.privateAttributesFrom)(context);
|
|
1165
1515
|
const { userId: _uid, ...rest } = legacy.attributes;
|
|
1166
1516
|
return {
|
|
1167
1517
|
userId,
|
|
@@ -1171,18 +1521,7 @@ var AvsbServer = class {
|
|
|
1171
1521
|
};
|
|
1172
1522
|
}
|
|
1173
1523
|
// ---------------------------------------------------------------------------
|
|
1174
|
-
// Private —
|
|
1175
|
-
// ---------------------------------------------------------------------------
|
|
1176
|
-
resolveRuntimeOverride(flagKey, userId) {
|
|
1177
|
-
const entry = this.overrides.get(flagKey);
|
|
1178
|
-
if (!entry) return void 0;
|
|
1179
|
-
const userVk = entry.perKindKey.get(`user:${userId}`);
|
|
1180
|
-
const vk = userVk ?? entry.global;
|
|
1181
|
-
if (!vk) return void 0;
|
|
1182
|
-
return this.findVariation(flagKey, vk) ?? void 0;
|
|
1183
|
-
}
|
|
1184
|
-
// ---------------------------------------------------------------------------
|
|
1185
|
-
// Private — sticky bucket helpers
|
|
1524
|
+
// Private — sticky helpers
|
|
1186
1525
|
// ---------------------------------------------------------------------------
|
|
1187
1526
|
resolveStickyBucket(flagKey, userId) {
|
|
1188
1527
|
if (!this.stickyBucketService || !this.datafile) return void 0;
|
|
@@ -1224,7 +1563,7 @@ var AvsbServer = class {
|
|
|
1224
1563
|
});
|
|
1225
1564
|
return;
|
|
1226
1565
|
}
|
|
1227
|
-
this.
|
|
1566
|
+
this.deliverExposure({
|
|
1228
1567
|
visitorId: uc.userId,
|
|
1229
1568
|
experimentId: rule.id,
|
|
1230
1569
|
variationId: sticky.vref.id,
|
|
@@ -1236,25 +1575,7 @@ var AvsbServer = class {
|
|
|
1236
1575
|
});
|
|
1237
1576
|
}
|
|
1238
1577
|
// ---------------------------------------------------------------------------
|
|
1239
|
-
// Private —
|
|
1240
|
-
// ---------------------------------------------------------------------------
|
|
1241
|
-
emit(event, ...args) {
|
|
1242
|
-
const cbs = this.listeners.get(event);
|
|
1243
|
-
if (!cbs) return;
|
|
1244
|
-
for (const cb of cbs) {
|
|
1245
|
-
try {
|
|
1246
|
-
;
|
|
1247
|
-
cb(...args);
|
|
1248
|
-
} catch (e) {
|
|
1249
|
-
this.logger.error("Listener error", { event, error: String(e) });
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
emitEvaluation(flagKey, value, userId, source) {
|
|
1254
|
-
this.emit("evaluation", { flagKey, value, userId, source });
|
|
1255
|
-
}
|
|
1256
|
-
// ---------------------------------------------------------------------------
|
|
1257
|
-
// Private — tracker + polling + streaming
|
|
1578
|
+
// Private — tracker, polling, streaming, heartbeat
|
|
1258
1579
|
// ---------------------------------------------------------------------------
|
|
1259
1580
|
initTracker() {
|
|
1260
1581
|
if (!this.datafile || this.tracker) return;
|
|
@@ -1270,152 +1591,143 @@ var AvsbServer = class {
|
|
|
1270
1591
|
if (this.pendingEvents.length > 0) {
|
|
1271
1592
|
const queued = this.pendingEvents;
|
|
1272
1593
|
this.pendingEvents = [];
|
|
1273
|
-
for (const item of queued)
|
|
1274
|
-
this.track(item.eventKey, item.payload);
|
|
1275
|
-
}
|
|
1594
|
+
for (const item of queued) this.track(item.eventKey, item.payload);
|
|
1276
1595
|
}
|
|
1596
|
+
if (this.pendingPurchases.length > 0) {
|
|
1597
|
+
const queued = this.pendingPurchases;
|
|
1598
|
+
this.pendingPurchases = [];
|
|
1599
|
+
for (const item of queued) void this.trackPurchase(item.visitorId, item.order);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
enqueuePending(eventKey, payload) {
|
|
1603
|
+
if (this.pendingEvents.length >= this.eventOptions.maxQueueSize) {
|
|
1604
|
+
this.pendingEvents.shift();
|
|
1605
|
+
this.warnOnce(
|
|
1606
|
+
"pending-events-full",
|
|
1607
|
+
`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.`
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
this.pendingEvents.push({ eventKey, payload });
|
|
1277
1611
|
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Fetch and apply the datafile.
|
|
1614
|
+
*
|
|
1615
|
+
* Three outcomes, not two, because a conditional request has three:
|
|
1616
|
+
* 'applied' a datafile came back and is now in memory
|
|
1617
|
+
* 'unchanged' the CDN answered 304: what we hold IS current
|
|
1618
|
+
* 'failed' nothing usable came back; the error is already logged
|
|
1619
|
+
*
|
|
1620
|
+
* 'unchanged' is a success. Collapsing it into 'failed' made
|
|
1621
|
+
* `refreshDatafile()` warn that a refresh "did not apply a new datafile"
|
|
1622
|
+
* every time the datafile was simply up to date.
|
|
1623
|
+
*/
|
|
1624
|
+
async loadDatafile(reason = "manual") {
|
|
1625
|
+
const outcome = await fetchDatafile({
|
|
1626
|
+
sdkKey: this.sdkKey,
|
|
1627
|
+
cdnHost: this.cdnHost,
|
|
1628
|
+
signal: this.abortController.signal,
|
|
1629
|
+
logger: this.logger,
|
|
1630
|
+
etag: this.datafileEtag
|
|
1631
|
+
});
|
|
1632
|
+
if (outcome.status === "ok" || outcome.status === "not-modified") {
|
|
1633
|
+
this.sendHeartbeat();
|
|
1634
|
+
}
|
|
1635
|
+
if (outcome.status === "not-modified") return "unchanged";
|
|
1636
|
+
if (outcome.status !== "ok") return "failed";
|
|
1637
|
+
const changed = !this.datafile || this.datafile.publishedAt !== outcome.datafile.publishedAt;
|
|
1638
|
+
this.datafile = outcome.datafile;
|
|
1639
|
+
this.datafileEtag = outcome.etag;
|
|
1640
|
+
if (this.ready && changed) {
|
|
1641
|
+
this.emit("configUpdate", {
|
|
1642
|
+
publishedAt: outcome.datafile.publishedAt,
|
|
1643
|
+
reason
|
|
1644
|
+
});
|
|
1645
|
+
this.logger.info("Datafile updated", {
|
|
1646
|
+
publishedAt: outcome.datafile.publishedAt,
|
|
1647
|
+
reason
|
|
1648
|
+
});
|
|
1649
|
+
this.startStreamingIfEnabled();
|
|
1650
|
+
}
|
|
1651
|
+
return "applied";
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Poll on a JITTERED interval. Without the jitter every process that
|
|
1655
|
+
* deployed together polls the CDN on the same tick forever.
|
|
1656
|
+
*/
|
|
1657
|
+
startPolling() {
|
|
1658
|
+
if (this.pollingTimer || this.destroyed) return;
|
|
1659
|
+
const schedule = () => {
|
|
1660
|
+
this.pollingTimer = setTimeout(() => {
|
|
1661
|
+
this.pollingTimer = null;
|
|
1662
|
+
void (async () => {
|
|
1663
|
+
const result = await this.loadDatafile("poll");
|
|
1664
|
+
if (result === "applied" && !this.ready) this.onDatafileReadable();
|
|
1665
|
+
if (!this.destroyed) schedule();
|
|
1666
|
+
})();
|
|
1667
|
+
}, (0, import_backoff2.jitteredInterval)(this.pollingInterval));
|
|
1668
|
+
};
|
|
1669
|
+
schedule();
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Open the SSE stream when one is available, and fall back to polling in
|
|
1673
|
+
* silence when it is not (B12). The endpoint comes from the datafile's
|
|
1674
|
+
* `streamEndpoint` field, or an explicit option; it is never guessed from
|
|
1675
|
+
* another host, which is what gave one port an infinite reconnect loop
|
|
1676
|
+
* against DNS that does not resolve.
|
|
1677
|
+
*/
|
|
1278
1678
|
startStreamingIfEnabled() {
|
|
1279
1679
|
if (!this.streamingEnabled || this.streamingClient || this.destroyed) return;
|
|
1680
|
+
const resolution = (0, import_core8.resolveStreamEndpoint)({
|
|
1681
|
+
optionEndpoint: this.streamingEndpointOption,
|
|
1682
|
+
datafileStreamEndpoint: this.datafile?.streamEndpoint ?? null
|
|
1683
|
+
});
|
|
1684
|
+
if (!resolution.available) {
|
|
1685
|
+
this.logger.debug(`Streaming stays off, polling continues. ${resolution.reason}`);
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1280
1688
|
this.streamingClient = (0, import_streaming.createStreamingClient)({
|
|
1281
|
-
endpoint:
|
|
1689
|
+
endpoint: resolution.endpoint,
|
|
1282
1690
|
sdkKey: this.sdkKey,
|
|
1283
1691
|
onDatafileUpdated: () => {
|
|
1284
|
-
void this.
|
|
1692
|
+
void this.loadDatafile("stream");
|
|
1285
1693
|
},
|
|
1286
1694
|
logger: this.logger
|
|
1287
1695
|
});
|
|
1288
1696
|
this.streamingClient.connect();
|
|
1697
|
+
this.logger.debug(
|
|
1698
|
+
`Streaming datafile updates from ${resolution.endpoint} (${resolution.source}).`
|
|
1699
|
+
);
|
|
1289
1700
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
startPolling() {
|
|
1306
|
-
if (this.pollingId || this.destroyed) return;
|
|
1307
|
-
this.pollingId = setInterval(async () => {
|
|
1308
|
-
const df = await this.fetchDatafile();
|
|
1309
|
-
if (!df) return;
|
|
1310
|
-
const changed = !this.datafile || this.datafile.publishedAt !== df.publishedAt;
|
|
1311
|
-
if (!this.ready) {
|
|
1312
|
-
this.datafile = df;
|
|
1313
|
-
this.initTracker();
|
|
1314
|
-
this.ready = true;
|
|
1315
|
-
this.emit("ready");
|
|
1316
|
-
} else if (changed) {
|
|
1317
|
-
this.datafile = df;
|
|
1318
|
-
this.emit("configUpdate", { publishedAt: df.publishedAt });
|
|
1319
|
-
this.logger.info("Datafile updated", { publishedAt: df.publishedAt });
|
|
1320
|
-
}
|
|
1321
|
-
}, this.pollingInterval);
|
|
1322
|
-
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Report liveness, throttled.
|
|
1703
|
+
*
|
|
1704
|
+
* Called on every successful datafile load, not just at init: the dashboard
|
|
1705
|
+
* marks an environment Stale after five minutes without a heartbeat, so an
|
|
1706
|
+
* init-only heartbeat made every long-running process look dead a few
|
|
1707
|
+
* minutes after it started. The throttle keeps a fast polling interval (or a
|
|
1708
|
+
* burst of stream-triggered refreshes) from turning liveness into traffic.
|
|
1709
|
+
*
|
|
1710
|
+
* Fire and forget: a failed heartbeat never affects evaluation, and the
|
|
1711
|
+
* throttle clock is stamped by the synchronous send below, so a hanging
|
|
1712
|
+
* request cannot queue a second one.
|
|
1713
|
+
*/
|
|
1323
1714
|
sendHeartbeat() {
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
sdkVersion: SDK_VERSION
|
|
1332
|
-
}),
|
|
1715
|
+
const now = Date.now();
|
|
1716
|
+
if (now - this.lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
|
|
1717
|
+
const posted = sendHeartbeat({
|
|
1718
|
+
sdkKey: this.sdkKey,
|
|
1719
|
+
sdkType: SDK_TYPE,
|
|
1720
|
+
sdkVersion: SDK_VERSION,
|
|
1721
|
+
datafileHeartbeatEndpoint: this.datafile?.heartbeatEndpoint,
|
|
1333
1722
|
signal: this.abortController.signal
|
|
1334
|
-
}).catch(() => {
|
|
1335
1723
|
});
|
|
1724
|
+
if (posted) this.lastHeartbeatAt = now;
|
|
1336
1725
|
}
|
|
1337
1726
|
};
|
|
1338
1727
|
|
|
1339
|
-
// src/
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
value: defaultValue,
|
|
1343
|
-
variationKey: null,
|
|
1344
|
-
source: "not_found",
|
|
1345
|
-
ruleId: null,
|
|
1346
|
-
ruleType: null,
|
|
1347
|
-
reasons: ["no userId available"],
|
|
1348
|
-
evaluatedAt: Date.now(),
|
|
1349
|
-
durationMicros: 0,
|
|
1350
|
-
isEnabled: () => false,
|
|
1351
|
-
exists: () => false
|
|
1352
|
-
});
|
|
1353
|
-
return {
|
|
1354
|
-
getFlag(_flagKey, defaultValue) {
|
|
1355
|
-
return noopFlag(defaultValue);
|
|
1356
|
-
},
|
|
1357
|
-
getBoolFlag(_flagKey, defaultValue) {
|
|
1358
|
-
return noopFlag(defaultValue);
|
|
1359
|
-
},
|
|
1360
|
-
getStringFlag(_flagKey, defaultValue) {
|
|
1361
|
-
return noopFlag(defaultValue);
|
|
1362
|
-
},
|
|
1363
|
-
getNumberFlag(_flagKey, defaultValue) {
|
|
1364
|
-
return noopFlag(defaultValue);
|
|
1365
|
-
},
|
|
1366
|
-
getJsonFlag(_flagKey, defaultValue) {
|
|
1367
|
-
return noopFlag(defaultValue);
|
|
1368
|
-
},
|
|
1369
|
-
getAllFlags() {
|
|
1370
|
-
return {};
|
|
1371
|
-
},
|
|
1372
|
-
track() {
|
|
1373
|
-
},
|
|
1374
|
-
setForcedDecision() {
|
|
1375
|
-
},
|
|
1376
|
-
clearForcedDecision() {
|
|
1377
|
-
},
|
|
1378
|
-
getDecisionLog() {
|
|
1379
|
-
return { entries: [] };
|
|
1380
|
-
}
|
|
1381
|
-
};
|
|
1382
|
-
}
|
|
1383
|
-
function expressMiddleware(client, config) {
|
|
1384
|
-
return (req, res, next) => {
|
|
1385
|
-
const userId = config.userIdFrom(req);
|
|
1386
|
-
if (!userId) {
|
|
1387
|
-
;
|
|
1388
|
-
req.avsb = createNoopUserBoundClient();
|
|
1389
|
-
return next();
|
|
1390
|
-
}
|
|
1391
|
-
const attributes = config.attributesFrom?.(req) ?? {};
|
|
1392
|
-
const context = { kind: "user", key: userId, ...attributes };
|
|
1393
|
-
req.avsb = client.forUser(context);
|
|
1394
|
-
next();
|
|
1395
|
-
};
|
|
1396
|
-
}
|
|
1397
|
-
function fastifyPlugin(instance, opts, done) {
|
|
1398
|
-
const fastify = instance;
|
|
1399
|
-
fastify.decorateRequest("avsb", null);
|
|
1400
|
-
fastify.addHook("onRequest", (...args) => {
|
|
1401
|
-
const req = args[0];
|
|
1402
|
-
const hookDone = args[2];
|
|
1403
|
-
const userId = opts.userIdFrom(req);
|
|
1404
|
-
if (!userId) {
|
|
1405
|
-
;
|
|
1406
|
-
req.avsb = createNoopUserBoundClient();
|
|
1407
|
-
return hookDone();
|
|
1408
|
-
}
|
|
1409
|
-
const attributes = opts.attributesFrom?.(req) ?? {};
|
|
1410
|
-
const context = { kind: "user", key: userId, ...attributes };
|
|
1411
|
-
req.avsb = opts.client.forUser(context);
|
|
1412
|
-
hookDone();
|
|
1413
|
-
});
|
|
1414
|
-
done();
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
// src/logger.ts
|
|
1418
|
-
var import_core3 = require("@avsbhq/core");
|
|
1728
|
+
// src/index.ts
|
|
1729
|
+
var import_express = require("@avsbhq/utils/middleware/express");
|
|
1730
|
+
var import_fastify = require("@avsbhq/utils/middleware/fastify");
|
|
1419
1731
|
|
|
1420
1732
|
// src/stickyBucket.ts
|
|
1421
1733
|
var InMemoryStickyBucketService = class {
|
|
@@ -1447,7 +1759,7 @@ var RedisStickyBucketService = class {
|
|
|
1447
1759
|
this.prefix = options.prefix ?? "avsb:sticky:";
|
|
1448
1760
|
this.ttl = options.ttl ?? 2592e3;
|
|
1449
1761
|
this.maxCacheSize = options.cacheSize ?? 1e4;
|
|
1450
|
-
this.logger = options.logger ??
|
|
1762
|
+
this.logger = options.logger ?? import_core4.noopLogger;
|
|
1451
1763
|
}
|
|
1452
1764
|
redisKey(userId, flagKey) {
|
|
1453
1765
|
return `${this.prefix}${userId}:${flagKey}`;
|
|
@@ -1509,11 +1821,88 @@ var RedisStickyBucketService = class {
|
|
|
1509
1821
|
}
|
|
1510
1822
|
};
|
|
1511
1823
|
|
|
1824
|
+
// src/webhookSignature.ts
|
|
1825
|
+
var import_crypto = __toESM(require("crypto"), 1);
|
|
1826
|
+
var DEFAULT_TOLERANCE_SECONDS = 300;
|
|
1827
|
+
var HEADER_ID = "x-avsb-delivery-id";
|
|
1828
|
+
var HEADER_TIMESTAMP = "x-avsb-timestamp";
|
|
1829
|
+
var HEADER_SIGNATURE = "x-avsb-signature";
|
|
1830
|
+
function readHeader(headers, name) {
|
|
1831
|
+
if (typeof headers.get === "function") {
|
|
1832
|
+
return headers.get(name);
|
|
1833
|
+
}
|
|
1834
|
+
const bag = headers;
|
|
1835
|
+
const value = bag[name] ?? bag[name.toUpperCase()] ?? findCaseInsensitive(bag, name);
|
|
1836
|
+
if (value === void 0) return null;
|
|
1837
|
+
return Array.isArray(value) ? value[0] ?? null : value;
|
|
1838
|
+
}
|
|
1839
|
+
function findCaseInsensitive(bag, name) {
|
|
1840
|
+
for (const key of Object.keys(bag)) {
|
|
1841
|
+
if (key.toLowerCase() === name) return bag[key];
|
|
1842
|
+
}
|
|
1843
|
+
return void 0;
|
|
1844
|
+
}
|
|
1845
|
+
function digestsMatch(a, b) {
|
|
1846
|
+
if (a.length !== b.length) return false;
|
|
1847
|
+
return import_crypto.default.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
1848
|
+
}
|
|
1849
|
+
function verifyWebhookSignature(payload, headers, secret, options = {}) {
|
|
1850
|
+
const deliveryId = readHeader(headers, HEADER_ID);
|
|
1851
|
+
const timestamp = readHeader(headers, HEADER_TIMESTAMP);
|
|
1852
|
+
const signatureHeader = readHeader(headers, HEADER_SIGNATURE);
|
|
1853
|
+
if (!deliveryId || !timestamp || !signatureHeader) {
|
|
1854
|
+
return {
|
|
1855
|
+
ok: false,
|
|
1856
|
+
reason: "missing_headers",
|
|
1857
|
+
message: `Expected ${HEADER_ID}, ${HEADER_TIMESTAMP} and ${HEADER_SIGNATURE} headers. Got id=${Boolean(deliveryId)} timestamp=${Boolean(timestamp)} signature=${Boolean(signatureHeader)}.`
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
if (!signatureHeader.startsWith("sha256=")) {
|
|
1861
|
+
return {
|
|
1862
|
+
ok: false,
|
|
1863
|
+
reason: "malformed_signature",
|
|
1864
|
+
message: `${HEADER_SIGNATURE} must look like "sha256=<hex>", got "${signatureHeader.slice(0, 16)}\u2026".`
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
const provided = signatureHeader.slice("sha256=".length);
|
|
1868
|
+
const signedAtMs = Date.parse(timestamp);
|
|
1869
|
+
if (Number.isNaN(signedAtMs)) {
|
|
1870
|
+
return {
|
|
1871
|
+
ok: false,
|
|
1872
|
+
reason: "malformed_timestamp",
|
|
1873
|
+
message: `${HEADER_TIMESTAMP} is not an ISO-8601 instant: "${timestamp}".`
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
1877
|
+
if (tolerance > 0) {
|
|
1878
|
+
const nowMs = (options.now ?? Date.now)();
|
|
1879
|
+
const ageSeconds = Math.abs(nowMs - signedAtMs) / 1e3;
|
|
1880
|
+
if (ageSeconds > tolerance) {
|
|
1881
|
+
return {
|
|
1882
|
+
ok: false,
|
|
1883
|
+
reason: "timestamp_out_of_tolerance",
|
|
1884
|
+
message: `Delivery signed ${Math.round(ageSeconds)}s from now, outside the ${tolerance}s tolerance. Check your server clock, or raise toleranceSeconds.`
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
const body = typeof payload === "string" ? payload : payload.toString("utf8");
|
|
1889
|
+
const expected = import_crypto.default.createHmac("sha256", secret).update(`${deliveryId}.${timestamp}.${body}`).digest("hex");
|
|
1890
|
+
if (!digestsMatch(provided, expected)) {
|
|
1891
|
+
return {
|
|
1892
|
+
ok: false,
|
|
1893
|
+
reason: "signature_mismatch",
|
|
1894
|
+
message: "Signature does not match. Most common causes: the body was parsed and re-serialised before verification (use the raw bytes), or the secret is from a different webhook."
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
return { ok: true, deliveryId, timestamp };
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1512
1900
|
// src/index.ts
|
|
1513
|
-
var
|
|
1901
|
+
var import_core9 = require("@avsbhq/core");
|
|
1514
1902
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1515
1903
|
0 && (module.exports = {
|
|
1516
1904
|
AvsbServer,
|
|
1905
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
1517
1906
|
DecideOption,
|
|
1518
1907
|
InMemoryStickyBucketService,
|
|
1519
1908
|
RedisStickyBucketService,
|
|
@@ -1523,7 +1912,11 @@ var import_core4 = require("@avsbhq/core");
|
|
|
1523
1912
|
createDecisionLog,
|
|
1524
1913
|
createLogger,
|
|
1525
1914
|
createStreamingClient,
|
|
1915
|
+
defaultLogger,
|
|
1526
1916
|
expressMiddleware,
|
|
1527
1917
|
fastifyPlugin,
|
|
1528
|
-
|
|
1918
|
+
isDevEnvironment,
|
|
1919
|
+
noopLogger,
|
|
1920
|
+
resolveServerLogger,
|
|
1921
|
+
verifyWebhookSignature
|
|
1529
1922
|
});
|