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