@shipeasy/sdk 5.0.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -0
- package/dist/client/index.d.mts +169 -4
- package/dist/client/index.d.ts +169 -4
- package/dist/client/index.js +228 -26
- package/dist/client/index.mjs +227 -26
- package/dist/openfeature-server/index.d.mts +338 -0
- package/dist/openfeature-server/index.d.ts +338 -0
- package/dist/openfeature-server/index.js +125 -0
- package/dist/openfeature-server/index.mjs +100 -0
- package/dist/openfeature-web/index.d.mts +332 -0
- package/dist/openfeature-web/index.d.ts +332 -0
- package/dist/openfeature-web/index.js +120 -0
- package/dist/openfeature-web/index.mjs +95 -0
- package/dist/server/index.d.mts +198 -4
- package/dist/server/index.d.ts +198 -4
- package/dist/server/index.js +290 -36
- package/dist/server/index.mjs +294 -36
- package/package.json +23 -1
package/dist/server/index.js
CHANGED
|
@@ -31,9 +31,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var server_exports = {};
|
|
32
32
|
__export(server_exports, {
|
|
33
33
|
ANON_ID_COOKIE: () => ANON_ID_COOKIE,
|
|
34
|
+
FLAG_REASONS: () => FLAG_REASONS,
|
|
34
35
|
FlagsClient: () => FlagsClient,
|
|
36
|
+
_murmur3ForTests: () => _murmur3ForTests,
|
|
35
37
|
_resetShipeasyServerForTests: () => _resetShipeasyServerForTests,
|
|
36
38
|
configureShipeasyServer: () => configureShipeasyServer,
|
|
39
|
+
createInMemoryStickyStore: () => createInMemoryStickyStore,
|
|
37
40
|
fetchLabelsForSSR: () => fetchLabelsForSSR,
|
|
38
41
|
flags: () => flags,
|
|
39
42
|
getBootstrapHtml: () => getBootstrapHtml,
|
|
@@ -362,6 +365,9 @@ var SeeLimiter = class {
|
|
|
362
365
|
var version = "4.0.0";
|
|
363
366
|
var C1 = 3432918353;
|
|
364
367
|
var C2 = 461845907;
|
|
368
|
+
function _murmur3ForTests(key) {
|
|
369
|
+
return murmur3(key);
|
|
370
|
+
}
|
|
365
371
|
function murmur3(key) {
|
|
366
372
|
const bytes = new TextEncoder().encode(key);
|
|
367
373
|
const len = bytes.length;
|
|
@@ -402,6 +408,25 @@ function murmur3(key) {
|
|
|
402
408
|
h1 ^= h1 >>> 16;
|
|
403
409
|
return h1 >>> 0;
|
|
404
410
|
}
|
|
411
|
+
var FLAG_REASONS = [
|
|
412
|
+
"CLIENT_NOT_READY",
|
|
413
|
+
"FLAG_NOT_FOUND",
|
|
414
|
+
"OFF",
|
|
415
|
+
"OVERRIDE",
|
|
416
|
+
"RULE_MATCH",
|
|
417
|
+
"DEFAULT"
|
|
418
|
+
];
|
|
419
|
+
function createInMemoryStickyStore(seed) {
|
|
420
|
+
const m = new Map(Object.entries(seed ?? {}));
|
|
421
|
+
return {
|
|
422
|
+
get: (unit) => m.get(unit),
|
|
423
|
+
set: (unit, exp, entry) => {
|
|
424
|
+
const cur = m.get(unit) ?? {};
|
|
425
|
+
cur[exp] = entry;
|
|
426
|
+
m.set(unit, cur);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
405
430
|
function isEnabled(v) {
|
|
406
431
|
return v === 1 || v === true;
|
|
407
432
|
}
|
|
@@ -454,6 +479,14 @@ var ANON_ID_RX = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
454
479
|
function mintAnonId() {
|
|
455
480
|
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `anon_${Math.random().toString(36).slice(2)}`;
|
|
456
481
|
}
|
|
482
|
+
function pickIdentifier(user, bucketBy) {
|
|
483
|
+
if (bucketBy) {
|
|
484
|
+
const v = user[bucketBy];
|
|
485
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
486
|
+
if (typeof v === "number") return String(v);
|
|
487
|
+
}
|
|
488
|
+
return user.user_id ?? user.anonymous_id;
|
|
489
|
+
}
|
|
457
490
|
function evalGateInternal(gate, user) {
|
|
458
491
|
if (isEnabled(gate.killswitch)) return false;
|
|
459
492
|
if (!isEnabled(gate.enabled)) return false;
|
|
@@ -509,10 +542,12 @@ function parseOverrides(rawUrl) {
|
|
|
509
542
|
}
|
|
510
543
|
return { gates, configs, experiments };
|
|
511
544
|
}
|
|
512
|
-
var FlagsClient = class {
|
|
545
|
+
var FlagsClient = class _FlagsClient {
|
|
513
546
|
apiKey;
|
|
514
547
|
baseUrl;
|
|
515
548
|
env;
|
|
549
|
+
privateAttributes;
|
|
550
|
+
stickyStore;
|
|
516
551
|
telemetry;
|
|
517
552
|
seeLimiter = new SeeLimiter();
|
|
518
553
|
flagsBlob = null;
|
|
@@ -522,47 +557,157 @@ var FlagsClient = class {
|
|
|
522
557
|
pollInterval = 30;
|
|
523
558
|
timer = null;
|
|
524
559
|
initialized = false;
|
|
560
|
+
// Test mode: built by `FlagsClient.forTesting()`. When set, init()/initOnce()
|
|
561
|
+
// never fetch, track() is a no-op, and telemetry is off — the client is a
|
|
562
|
+
// fully self-contained, network-free seam for unit tests.
|
|
563
|
+
testMode;
|
|
564
|
+
// Programmatic overrides (Statsig-style). Set on any client via
|
|
565
|
+
// overrideFlag/overrideConfig/overrideExperiment; they win over the fetched
|
|
566
|
+
// blob in getFlag/getConfig/getExperiment. Cleared by clearOverrides().
|
|
567
|
+
flagOverrides = /* @__PURE__ */ new Map();
|
|
568
|
+
configOverrides = /* @__PURE__ */ new Map();
|
|
569
|
+
experimentOverrides = /* @__PURE__ */ new Map();
|
|
570
|
+
// Change listeners fired after a background poll returns NEW data (200, not
|
|
571
|
+
// 304). Never fired in testMode/offline (no polling happens there).
|
|
572
|
+
changeListeners = /* @__PURE__ */ new Set();
|
|
525
573
|
constructor(opts) {
|
|
526
574
|
this.apiKey = opts.apiKey;
|
|
527
575
|
this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
|
|
528
576
|
this.env = opts.env ?? "prod";
|
|
577
|
+
this.privateAttributes = opts.privateAttributes ?? [];
|
|
578
|
+
this.stickyStore = opts.stickyStore;
|
|
579
|
+
this.testMode = opts.testMode === true;
|
|
529
580
|
this.telemetry = new Telemetry({
|
|
530
581
|
endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
|
|
531
582
|
sdkKey: this.apiKey,
|
|
532
583
|
side: "server",
|
|
533
584
|
env: this.env,
|
|
534
|
-
|
|
585
|
+
// Test mode never talks to the network — telemetry off regardless of opt.
|
|
586
|
+
disabled: this.testMode || opts.disableTelemetry
|
|
535
587
|
});
|
|
536
|
-
if (opts.initialBlob) {
|
|
537
|
-
this.flagsBlob = opts.initialBlob;
|
|
588
|
+
if (opts.initialBlob || this.testMode) {
|
|
589
|
+
this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
|
|
590
|
+
this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
|
|
538
591
|
this.initialized = true;
|
|
539
592
|
}
|
|
540
593
|
}
|
|
594
|
+
/**
|
|
595
|
+
* Build a no-network, immediately-usable client for tests (Statsig-style).
|
|
596
|
+
* init()/initOnce() are no-ops (never fetch), track() is a no-op, telemetry
|
|
597
|
+
* is disabled, and the client is already "initialized" — seed every entity
|
|
598
|
+
* with overrideFlag/overrideConfig/overrideExperiment. No SDK key required.
|
|
599
|
+
*
|
|
600
|
+
* ```ts
|
|
601
|
+
* const client = FlagsClient.forTesting();
|
|
602
|
+
* client.overrideFlag("new_checkout", true);
|
|
603
|
+
* client.getFlag("new_checkout", { user_id: "u1" }); // true
|
|
604
|
+
* ```
|
|
605
|
+
*/
|
|
606
|
+
static forTesting(opts) {
|
|
607
|
+
return new _FlagsClient({ apiKey: "", ...opts, testMode: true });
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Build a fully OFFLINE client from a pre-captured snapshot — no network ever.
|
|
611
|
+
* Reuses the test-mode plumbing (init()/initOnce()/track() are no-ops,
|
|
612
|
+
* telemetry off) but, unlike forTesting(), seeds the REAL flags + experiments
|
|
613
|
+
* blobs so evaluations run the canonical eval against the snapshot. Local
|
|
614
|
+
* overrides still apply on top.
|
|
615
|
+
*
|
|
616
|
+
* Snapshot shape mirrors the wire bodies:
|
|
617
|
+
* `{ flags: <GET /sdk/flags body>, experiments: <GET /sdk/experiments body> }`.
|
|
618
|
+
*/
|
|
619
|
+
static fromSnapshot(snapshot) {
|
|
620
|
+
const client = new _FlagsClient({ apiKey: "", testMode: true });
|
|
621
|
+
client.flagsBlob = snapshot.flags;
|
|
622
|
+
client.expsBlob = snapshot.experiments;
|
|
623
|
+
client.initialized = true;
|
|
624
|
+
return client;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Build a fully OFFLINE client from a snapshot JSON file on disk (Node only —
|
|
628
|
+
* not available in the browser entrypoint). The file must contain
|
|
629
|
+
* `{ "flags": <GET /sdk/flags body>, "experiments": <GET /sdk/experiments body> }`.
|
|
630
|
+
* See {@link FlagsClient.fromSnapshot}.
|
|
631
|
+
*/
|
|
632
|
+
static fromFile(path) {
|
|
633
|
+
const fs = require("fs");
|
|
634
|
+
const raw = fs.readFileSync(path, "utf8");
|
|
635
|
+
const snapshot = JSON.parse(raw);
|
|
636
|
+
return _FlagsClient.fromSnapshot(snapshot);
|
|
637
|
+
}
|
|
541
638
|
async init() {
|
|
639
|
+
if (this.testMode) {
|
|
640
|
+
this.initialized = true;
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
542
643
|
await this.fetchAll();
|
|
543
644
|
this.initialized = true;
|
|
544
645
|
this.startPoll();
|
|
545
646
|
}
|
|
546
647
|
async initOnce() {
|
|
547
|
-
if (this.initialized) return;
|
|
648
|
+
if (this.testMode || this.initialized) return;
|
|
548
649
|
await this.fetchAll();
|
|
549
650
|
this.initialized = true;
|
|
550
651
|
}
|
|
652
|
+
// ---- Local overrides (Statsig-style) ----
|
|
653
|
+
/** Force `getFlag(name, …)` to return `value`, ignoring the fetched gate. */
|
|
654
|
+
overrideFlag(name, value) {
|
|
655
|
+
this.flagOverrides.set(name, value);
|
|
656
|
+
}
|
|
657
|
+
/** Force `getConfig(name)` to return `value`, ignoring the fetched config. */
|
|
658
|
+
overrideConfig(name, value) {
|
|
659
|
+
this.configOverrides.set(name, value);
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
|
|
663
|
+
* ignoring allocation, holdouts, and targeting.
|
|
664
|
+
*/
|
|
665
|
+
overrideExperiment(name, group, params) {
|
|
666
|
+
this.experimentOverrides.set(name, { group, params });
|
|
667
|
+
}
|
|
668
|
+
/** Remove every programmatic override set via the override* methods. */
|
|
669
|
+
clearOverrides() {
|
|
670
|
+
this.flagOverrides.clear();
|
|
671
|
+
this.configOverrides.clear();
|
|
672
|
+
this.experimentOverrides.clear();
|
|
673
|
+
}
|
|
551
674
|
destroy() {
|
|
552
675
|
if (this.timer !== null) {
|
|
553
676
|
clearInterval(this.timer);
|
|
554
677
|
this.timer = null;
|
|
555
678
|
}
|
|
556
679
|
}
|
|
680
|
+
/**
|
|
681
|
+
* Subscribe to data-change notifications. The listener fires after a
|
|
682
|
+
* background poll fetch returns NEW data (200, not 304) — i.e. after the
|
|
683
|
+
* cached blob is updated. Never fires in testMode/offline (no polling).
|
|
684
|
+
* Returns an unsubscribe function.
|
|
685
|
+
*/
|
|
686
|
+
onChange(listener) {
|
|
687
|
+
this.changeListeners.add(listener);
|
|
688
|
+
return () => {
|
|
689
|
+
this.changeListeners.delete(listener);
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
notifyChange() {
|
|
693
|
+
for (const l of this.changeListeners) {
|
|
694
|
+
try {
|
|
695
|
+
l();
|
|
696
|
+
} catch (err) {
|
|
697
|
+
console.warn("[shipeasy] onChange listener threw:", String(err));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
557
701
|
startPoll() {
|
|
558
702
|
this.timer = setInterval(() => {
|
|
559
|
-
this.fetchAll().catch(
|
|
703
|
+
this.fetchAll(true).catch(
|
|
560
704
|
(err) => console.warn("[shipeasy] background poll failed:", String(err))
|
|
561
705
|
);
|
|
562
706
|
}, this.pollInterval * 1e3);
|
|
563
707
|
}
|
|
564
|
-
async fetchAll() {
|
|
565
|
-
const [
|
|
708
|
+
async fetchAll(fromPoll = false) {
|
|
709
|
+
const [flagsRes, expsChanged] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
|
|
710
|
+
const { interval, changed: flagsChanged } = flagsRes;
|
|
566
711
|
if (interval !== null && interval !== this.pollInterval) {
|
|
567
712
|
this.pollInterval = interval;
|
|
568
713
|
if (this.timer !== null) {
|
|
@@ -570,41 +715,72 @@ var FlagsClient = class {
|
|
|
570
715
|
this.startPoll();
|
|
571
716
|
}
|
|
572
717
|
}
|
|
718
|
+
if (fromPoll && (flagsChanged || expsChanged)) this.notifyChange();
|
|
573
719
|
}
|
|
574
720
|
async fetchFlags() {
|
|
575
721
|
const headers = { "X-SDK-Key": this.apiKey };
|
|
576
722
|
if (this.flagsEtag) headers["If-None-Match"] = this.flagsEtag;
|
|
577
723
|
const res = await globalThis.fetch(`${this.baseUrl}/sdk/flags?env=${this.env}`, { headers });
|
|
578
724
|
const interval = Number(res.headers.get("X-Poll-Interval") ?? "30") || 30;
|
|
579
|
-
if (res.status === 304) return interval;
|
|
725
|
+
if (res.status === 304) return { interval, changed: false };
|
|
580
726
|
if (!res.ok) throw new Error(`/sdk/flags returned ${res.status}`);
|
|
581
727
|
const etag = res.headers.get("ETag");
|
|
582
728
|
if (etag) this.flagsEtag = etag;
|
|
583
729
|
this.flagsBlob = await res.json();
|
|
584
|
-
return interval;
|
|
730
|
+
return { interval, changed: true };
|
|
585
731
|
}
|
|
586
732
|
async fetchExps() {
|
|
587
733
|
const headers = { "X-SDK-Key": this.apiKey };
|
|
588
734
|
if (this.expsEtag) headers["If-None-Match"] = this.expsEtag;
|
|
589
735
|
const res = await globalThis.fetch(`${this.baseUrl}/sdk/experiments`, { headers });
|
|
590
|
-
if (res.status === 304) return;
|
|
736
|
+
if (res.status === 304) return false;
|
|
591
737
|
if (!res.ok) throw new Error(`/sdk/experiments returned ${res.status}`);
|
|
592
738
|
const etag = res.headers.get("ETag");
|
|
593
739
|
if (etag) this.expsEtag = etag;
|
|
594
740
|
this.expsBlob = await res.json();
|
|
741
|
+
return true;
|
|
595
742
|
}
|
|
596
|
-
|
|
743
|
+
/**
|
|
744
|
+
* Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). The
|
|
745
|
+
* reason is computed entirely at this boundary — the canonical eval
|
|
746
|
+
* (evalGateInternal) is untouched. A local override short-circuits BEFORE
|
|
747
|
+
* telemetry, exactly like getFlag's override path; otherwise exactly one
|
|
748
|
+
* "gate" beacon is emitted.
|
|
749
|
+
*/
|
|
750
|
+
getFlagDetail(name, user) {
|
|
751
|
+
const ov = this.flagOverrides.get(name);
|
|
752
|
+
if (ov !== void 0) return { value: ov, reason: "OVERRIDE" };
|
|
597
753
|
this.telemetry.emit("gate", name);
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
754
|
+
if (!this.flagsBlob) return { value: false, reason: "CLIENT_NOT_READY" };
|
|
755
|
+
const gate = this.flagsBlob.gates[name];
|
|
756
|
+
if (!gate) return { value: false, reason: "FLAG_NOT_FOUND" };
|
|
757
|
+
if (isEnabled(gate.killswitch) || !isEnabled(gate.enabled)) {
|
|
758
|
+
return { value: false, reason: "OFF" };
|
|
759
|
+
}
|
|
760
|
+
const value = evalGateInternal(gate, user);
|
|
761
|
+
return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
|
|
601
762
|
}
|
|
602
|
-
|
|
763
|
+
/**
|
|
764
|
+
* Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
|
|
765
|
+
* evaluated (client not initialized or flag not found) — never for a gate that
|
|
766
|
+
* legitimately evaluates to false. Plain `getFlag(name, user)` keeps returning
|
|
767
|
+
* false for a missing flag.
|
|
768
|
+
*/
|
|
769
|
+
getFlag(name, user, defaultValue = false) {
|
|
770
|
+
const d = this.getFlagDetail(name, user);
|
|
771
|
+
if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
|
|
772
|
+
return d.value;
|
|
773
|
+
}
|
|
774
|
+
getConfig(name, decodeOrOpts) {
|
|
603
775
|
this.telemetry.emit("config", name);
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
776
|
+
const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
|
|
777
|
+
const has = this.configOverrides.has(name);
|
|
778
|
+
const raw = has ? this.configOverrides.get(name) : this.flagsBlob?.configs[name]?.value;
|
|
779
|
+
if (raw === void 0 && !has) {
|
|
780
|
+
return "defaultValue" in opts ? opts.defaultValue : void 0;
|
|
781
|
+
}
|
|
782
|
+
if (!opts.decode) return raw;
|
|
783
|
+
return opts.decode(raw);
|
|
608
784
|
}
|
|
609
785
|
getExperiment(name, user, defaultParams, decode) {
|
|
610
786
|
this.telemetry.emit("experiment", name);
|
|
@@ -613,6 +789,16 @@ var FlagsClient = class {
|
|
|
613
789
|
group: "control",
|
|
614
790
|
params: defaultParams
|
|
615
791
|
};
|
|
792
|
+
const ov = this.experimentOverrides.get(name);
|
|
793
|
+
if (ov) {
|
|
794
|
+
if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
|
|
795
|
+
try {
|
|
796
|
+
return { inExperiment: true, group: ov.group, params: decode(ov.params) };
|
|
797
|
+
} catch (err) {
|
|
798
|
+
console.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
|
|
799
|
+
return notIn;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
616
802
|
if (!this.flagsBlob || !this.expsBlob) return notIn;
|
|
617
803
|
const exp = this.expsBlob.experiments[name];
|
|
618
804
|
if (!exp || exp.status !== "running") return notIn;
|
|
@@ -620,7 +806,7 @@ var FlagsClient = class {
|
|
|
620
806
|
const gate = this.flagsBlob.gates[exp.targetingGate];
|
|
621
807
|
if (!gate || !evalGateInternal(gate, user)) return notIn;
|
|
622
808
|
}
|
|
623
|
-
const uid = user
|
|
809
|
+
const uid = pickIdentifier(user, exp.bucketBy);
|
|
624
810
|
if (!uid) return notIn;
|
|
625
811
|
const universe = this.expsBlob.universes[exp.universe];
|
|
626
812
|
const holdoutRange = universe?.holdout_range ?? null;
|
|
@@ -629,6 +815,23 @@ var FlagsClient = class {
|
|
|
629
815
|
const [lo, hi] = holdoutRange;
|
|
630
816
|
if (seg >= lo && seg <= hi) return notIn;
|
|
631
817
|
}
|
|
818
|
+
const asResult = (g) => {
|
|
819
|
+
if (!decode) return { inExperiment: true, group: g.name, params: g.params };
|
|
820
|
+
try {
|
|
821
|
+
return { inExperiment: true, group: g.name, params: decode(g.params) };
|
|
822
|
+
} catch (err) {
|
|
823
|
+
console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
|
|
824
|
+
return notIn;
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
const salt8 = exp.salt.slice(0, 8);
|
|
828
|
+
if (this.stickyStore) {
|
|
829
|
+
const entry = this.stickyStore.get(uid)?.[name];
|
|
830
|
+
if (entry && entry.s === salt8) {
|
|
831
|
+
const g = exp.groups.find((x) => x.name === entry.g);
|
|
832
|
+
if (g) return asResult(g);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
632
835
|
if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
|
|
633
836
|
const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
|
|
634
837
|
let cumulative = 0;
|
|
@@ -636,20 +839,24 @@ var FlagsClient = class {
|
|
|
636
839
|
const g = exp.groups[i];
|
|
637
840
|
cumulative += g.weight;
|
|
638
841
|
if (groupHash < cumulative || i === exp.groups.length - 1) {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
try {
|
|
643
|
-
return { inExperiment: true, group: g.name, params: decode(g.params) };
|
|
644
|
-
} catch (err) {
|
|
645
|
-
console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
|
|
646
|
-
return notIn;
|
|
647
|
-
}
|
|
842
|
+
this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
|
|
843
|
+
return asResult(g);
|
|
648
844
|
}
|
|
649
845
|
}
|
|
650
846
|
return notIn;
|
|
651
847
|
}
|
|
848
|
+
/** Drop caller-marked private attributes from an outbound props bag. */
|
|
849
|
+
stripPrivate(props) {
|
|
850
|
+
if (!props || this.privateAttributes.length === 0) return props;
|
|
851
|
+
const out = {};
|
|
852
|
+
for (const [k, v] of Object.entries(props)) {
|
|
853
|
+
if (!this.privateAttributes.includes(k)) out[k] = v;
|
|
854
|
+
}
|
|
855
|
+
return out;
|
|
856
|
+
}
|
|
652
857
|
track(userId, eventName, props) {
|
|
858
|
+
if (this.testMode) return;
|
|
859
|
+
const safeProps = this.stripPrivate(props);
|
|
653
860
|
const body = JSON.stringify({
|
|
654
861
|
events: [
|
|
655
862
|
{
|
|
@@ -657,7 +864,7 @@ var FlagsClient = class {
|
|
|
657
864
|
event_name: eventName,
|
|
658
865
|
user_id: userId,
|
|
659
866
|
ts: Date.now(),
|
|
660
|
-
...
|
|
867
|
+
...safeProps !== void 0 ? { properties: safeProps } : {}
|
|
661
868
|
}
|
|
662
869
|
]
|
|
663
870
|
});
|
|
@@ -667,6 +874,37 @@ var FlagsClient = class {
|
|
|
667
874
|
body
|
|
668
875
|
}).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
|
|
669
876
|
}
|
|
877
|
+
/**
|
|
878
|
+
* Emit an exposure event for an experiment at the server-side decision point
|
|
879
|
+
* (parity with the browser's auto-exposure). The server is stateless and
|
|
880
|
+
* never auto-logs, so call this when you actually present the treatment.
|
|
881
|
+
* Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
|
|
882
|
+
* as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
|
|
883
|
+
* `/collect`. No-op in test mode or when the user isn't enrolled.
|
|
884
|
+
*/
|
|
885
|
+
logExposure(user, name) {
|
|
886
|
+
if (this.testMode) return;
|
|
887
|
+
const u = typeof user === "string" ? { user_id: user } : user;
|
|
888
|
+
const result = this.getExperiment(name, u, {});
|
|
889
|
+
if (!result.inExperiment) return;
|
|
890
|
+
const body = JSON.stringify({
|
|
891
|
+
events: [
|
|
892
|
+
{
|
|
893
|
+
type: "exposure",
|
|
894
|
+
experiment: name,
|
|
895
|
+
group: result.group,
|
|
896
|
+
...u.user_id !== void 0 ? { user_id: u.user_id } : {},
|
|
897
|
+
...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
|
|
898
|
+
ts: Date.now()
|
|
899
|
+
}
|
|
900
|
+
]
|
|
901
|
+
});
|
|
902
|
+
globalThis.fetch(`${this.baseUrl}/collect`, {
|
|
903
|
+
method: "POST",
|
|
904
|
+
headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
|
|
905
|
+
body
|
|
906
|
+
}).catch((err) => console.warn("[shipeasy] logExposure failed:", String(err)));
|
|
907
|
+
}
|
|
670
908
|
/**
|
|
671
909
|
* Report a structured error into the errors primitive. Fire-and-forget —
|
|
672
910
|
* never blocks or throws into the request path. Spam-guarded by a 30s
|
|
@@ -873,7 +1111,11 @@ async function shipeasy(opts) {
|
|
|
873
1111
|
);
|
|
874
1112
|
}
|
|
875
1113
|
const profile = opts.i18nDefaultProfile ?? "en:prod";
|
|
876
|
-
flags.configure({
|
|
1114
|
+
flags.configure({
|
|
1115
|
+
apiKey: serverKey,
|
|
1116
|
+
disableTelemetry: opts.disableTelemetry,
|
|
1117
|
+
privateAttributes: opts.privateAttributes
|
|
1118
|
+
});
|
|
877
1119
|
let resolvedUrlOverrides = opts.urlOverrides;
|
|
878
1120
|
if (!resolvedUrlOverrides) {
|
|
879
1121
|
try {
|
|
@@ -984,11 +1226,15 @@ var flags = {
|
|
|
984
1226
|
destroy() {
|
|
985
1227
|
_server?.destroy();
|
|
986
1228
|
},
|
|
987
|
-
get(name, user) {
|
|
988
|
-
return _server?.getFlag(name, user) ??
|
|
1229
|
+
get(name, user, defaultValue = false) {
|
|
1230
|
+
return _server?.getFlag(name, user, defaultValue) ?? defaultValue;
|
|
989
1231
|
},
|
|
990
|
-
|
|
991
|
-
|
|
1232
|
+
/** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
|
|
1233
|
+
getDetail(name, user) {
|
|
1234
|
+
return _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" };
|
|
1235
|
+
},
|
|
1236
|
+
getConfig(name, decodeOrOpts) {
|
|
1237
|
+
return _server?.getConfig(name, decodeOrOpts);
|
|
992
1238
|
},
|
|
993
1239
|
getExperiment(name, user, defaultParams, decode) {
|
|
994
1240
|
return _server?.getExperiment(name, user, defaultParams, decode) ?? {
|
|
@@ -1008,6 +1254,11 @@ var flags = {
|
|
|
1008
1254
|
track(userId, eventName, props) {
|
|
1009
1255
|
_server?.track(userId, eventName, props);
|
|
1010
1256
|
},
|
|
1257
|
+
/** Emit an exposure for an enrolled experiment at the decision point. See
|
|
1258
|
+
* {@link FlagsClient.logExposure}. No-op before configure(). */
|
|
1259
|
+
logExposure(user, name) {
|
|
1260
|
+
_server?.logExposure(user, name);
|
|
1261
|
+
},
|
|
1011
1262
|
/**
|
|
1012
1263
|
* Evaluate all flags / configs / experiments for a user against the locally
|
|
1013
1264
|
* cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
|
|
@@ -1039,9 +1290,12 @@ var see = Object.assign(
|
|
|
1039
1290
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1040
1291
|
0 && (module.exports = {
|
|
1041
1292
|
ANON_ID_COOKIE,
|
|
1293
|
+
FLAG_REASONS,
|
|
1042
1294
|
FlagsClient,
|
|
1295
|
+
_murmur3ForTests,
|
|
1043
1296
|
_resetShipeasyServerForTests,
|
|
1044
1297
|
configureShipeasyServer,
|
|
1298
|
+
createInMemoryStickyStore,
|
|
1045
1299
|
fetchLabelsForSSR,
|
|
1046
1300
|
flags,
|
|
1047
1301
|
getBootstrapHtml,
|