@shipeasy/sdk 5.0.0 → 5.1.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 +84 -3
- package/dist/client/index.d.ts +84 -3
- package/dist/client/index.js +138 -19
- package/dist/client/index.mjs +137 -19
- package/dist/server/index.d.mts +141 -4
- package/dist/server/index.d.ts +141 -4
- package/dist/server/index.js +193 -24
- package/dist/server/index.mjs +198 -24
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -31,7 +31,9 @@ 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,
|
|
37
39
|
fetchLabelsForSSR: () => fetchLabelsForSSR,
|
|
@@ -362,6 +364,9 @@ var SeeLimiter = class {
|
|
|
362
364
|
var version = "4.0.0";
|
|
363
365
|
var C1 = 3432918353;
|
|
364
366
|
var C2 = 461845907;
|
|
367
|
+
function _murmur3ForTests(key) {
|
|
368
|
+
return murmur3(key);
|
|
369
|
+
}
|
|
365
370
|
function murmur3(key) {
|
|
366
371
|
const bytes = new TextEncoder().encode(key);
|
|
367
372
|
const len = bytes.length;
|
|
@@ -402,6 +407,14 @@ function murmur3(key) {
|
|
|
402
407
|
h1 ^= h1 >>> 16;
|
|
403
408
|
return h1 >>> 0;
|
|
404
409
|
}
|
|
410
|
+
var FLAG_REASONS = [
|
|
411
|
+
"CLIENT_NOT_READY",
|
|
412
|
+
"FLAG_NOT_FOUND",
|
|
413
|
+
"OFF",
|
|
414
|
+
"OVERRIDE",
|
|
415
|
+
"RULE_MATCH",
|
|
416
|
+
"DEFAULT"
|
|
417
|
+
];
|
|
405
418
|
function isEnabled(v) {
|
|
406
419
|
return v === 1 || v === true;
|
|
407
420
|
}
|
|
@@ -509,7 +522,7 @@ function parseOverrides(rawUrl) {
|
|
|
509
522
|
}
|
|
510
523
|
return { gates, configs, experiments };
|
|
511
524
|
}
|
|
512
|
-
var FlagsClient = class {
|
|
525
|
+
var FlagsClient = class _FlagsClient {
|
|
513
526
|
apiKey;
|
|
514
527
|
baseUrl;
|
|
515
528
|
env;
|
|
@@ -522,47 +535,155 @@ var FlagsClient = class {
|
|
|
522
535
|
pollInterval = 30;
|
|
523
536
|
timer = null;
|
|
524
537
|
initialized = false;
|
|
538
|
+
// Test mode: built by `FlagsClient.forTesting()`. When set, init()/initOnce()
|
|
539
|
+
// never fetch, track() is a no-op, and telemetry is off — the client is a
|
|
540
|
+
// fully self-contained, network-free seam for unit tests.
|
|
541
|
+
testMode;
|
|
542
|
+
// Programmatic overrides (Statsig-style). Set on any client via
|
|
543
|
+
// overrideFlag/overrideConfig/overrideExperiment; they win over the fetched
|
|
544
|
+
// blob in getFlag/getConfig/getExperiment. Cleared by clearOverrides().
|
|
545
|
+
flagOverrides = /* @__PURE__ */ new Map();
|
|
546
|
+
configOverrides = /* @__PURE__ */ new Map();
|
|
547
|
+
experimentOverrides = /* @__PURE__ */ new Map();
|
|
548
|
+
// Change listeners fired after a background poll returns NEW data (200, not
|
|
549
|
+
// 304). Never fired in testMode/offline (no polling happens there).
|
|
550
|
+
changeListeners = /* @__PURE__ */ new Set();
|
|
525
551
|
constructor(opts) {
|
|
526
552
|
this.apiKey = opts.apiKey;
|
|
527
553
|
this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
|
|
528
554
|
this.env = opts.env ?? "prod";
|
|
555
|
+
this.testMode = opts.testMode === true;
|
|
529
556
|
this.telemetry = new Telemetry({
|
|
530
557
|
endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
|
|
531
558
|
sdkKey: this.apiKey,
|
|
532
559
|
side: "server",
|
|
533
560
|
env: this.env,
|
|
534
|
-
|
|
561
|
+
// Test mode never talks to the network — telemetry off regardless of opt.
|
|
562
|
+
disabled: this.testMode || opts.disableTelemetry
|
|
535
563
|
});
|
|
536
|
-
if (opts.initialBlob) {
|
|
537
|
-
this.flagsBlob = opts.initialBlob;
|
|
564
|
+
if (opts.initialBlob || this.testMode) {
|
|
565
|
+
this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
|
|
566
|
+
this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
|
|
538
567
|
this.initialized = true;
|
|
539
568
|
}
|
|
540
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* Build a no-network, immediately-usable client for tests (Statsig-style).
|
|
572
|
+
* init()/initOnce() are no-ops (never fetch), track() is a no-op, telemetry
|
|
573
|
+
* is disabled, and the client is already "initialized" — seed every entity
|
|
574
|
+
* with overrideFlag/overrideConfig/overrideExperiment. No SDK key required.
|
|
575
|
+
*
|
|
576
|
+
* ```ts
|
|
577
|
+
* const client = FlagsClient.forTesting();
|
|
578
|
+
* client.overrideFlag("new_checkout", true);
|
|
579
|
+
* client.getFlag("new_checkout", { user_id: "u1" }); // true
|
|
580
|
+
* ```
|
|
581
|
+
*/
|
|
582
|
+
static forTesting(opts) {
|
|
583
|
+
return new _FlagsClient({ apiKey: "", ...opts, testMode: true });
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Build a fully OFFLINE client from a pre-captured snapshot — no network ever.
|
|
587
|
+
* Reuses the test-mode plumbing (init()/initOnce()/track() are no-ops,
|
|
588
|
+
* telemetry off) but, unlike forTesting(), seeds the REAL flags + experiments
|
|
589
|
+
* blobs so evaluations run the canonical eval against the snapshot. Local
|
|
590
|
+
* overrides still apply on top.
|
|
591
|
+
*
|
|
592
|
+
* Snapshot shape mirrors the wire bodies:
|
|
593
|
+
* `{ flags: <GET /sdk/flags body>, experiments: <GET /sdk/experiments body> }`.
|
|
594
|
+
*/
|
|
595
|
+
static fromSnapshot(snapshot) {
|
|
596
|
+
const client = new _FlagsClient({ apiKey: "", testMode: true });
|
|
597
|
+
client.flagsBlob = snapshot.flags;
|
|
598
|
+
client.expsBlob = snapshot.experiments;
|
|
599
|
+
client.initialized = true;
|
|
600
|
+
return client;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Build a fully OFFLINE client from a snapshot JSON file on disk (Node only —
|
|
604
|
+
* not available in the browser entrypoint). The file must contain
|
|
605
|
+
* `{ "flags": <GET /sdk/flags body>, "experiments": <GET /sdk/experiments body> }`.
|
|
606
|
+
* See {@link FlagsClient.fromSnapshot}.
|
|
607
|
+
*/
|
|
608
|
+
static fromFile(path) {
|
|
609
|
+
const fs = require("fs");
|
|
610
|
+
const raw = fs.readFileSync(path, "utf8");
|
|
611
|
+
const snapshot = JSON.parse(raw);
|
|
612
|
+
return _FlagsClient.fromSnapshot(snapshot);
|
|
613
|
+
}
|
|
541
614
|
async init() {
|
|
615
|
+
if (this.testMode) {
|
|
616
|
+
this.initialized = true;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
542
619
|
await this.fetchAll();
|
|
543
620
|
this.initialized = true;
|
|
544
621
|
this.startPoll();
|
|
545
622
|
}
|
|
546
623
|
async initOnce() {
|
|
547
|
-
if (this.initialized) return;
|
|
624
|
+
if (this.testMode || this.initialized) return;
|
|
548
625
|
await this.fetchAll();
|
|
549
626
|
this.initialized = true;
|
|
550
627
|
}
|
|
628
|
+
// ---- Local overrides (Statsig-style) ----
|
|
629
|
+
/** Force `getFlag(name, …)` to return `value`, ignoring the fetched gate. */
|
|
630
|
+
overrideFlag(name, value) {
|
|
631
|
+
this.flagOverrides.set(name, value);
|
|
632
|
+
}
|
|
633
|
+
/** Force `getConfig(name)` to return `value`, ignoring the fetched config. */
|
|
634
|
+
overrideConfig(name, value) {
|
|
635
|
+
this.configOverrides.set(name, value);
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
|
|
639
|
+
* ignoring allocation, holdouts, and targeting.
|
|
640
|
+
*/
|
|
641
|
+
overrideExperiment(name, group, params) {
|
|
642
|
+
this.experimentOverrides.set(name, { group, params });
|
|
643
|
+
}
|
|
644
|
+
/** Remove every programmatic override set via the override* methods. */
|
|
645
|
+
clearOverrides() {
|
|
646
|
+
this.flagOverrides.clear();
|
|
647
|
+
this.configOverrides.clear();
|
|
648
|
+
this.experimentOverrides.clear();
|
|
649
|
+
}
|
|
551
650
|
destroy() {
|
|
552
651
|
if (this.timer !== null) {
|
|
553
652
|
clearInterval(this.timer);
|
|
554
653
|
this.timer = null;
|
|
555
654
|
}
|
|
556
655
|
}
|
|
656
|
+
/**
|
|
657
|
+
* Subscribe to data-change notifications. The listener fires after a
|
|
658
|
+
* background poll fetch returns NEW data (200, not 304) — i.e. after the
|
|
659
|
+
* cached blob is updated. Never fires in testMode/offline (no polling).
|
|
660
|
+
* Returns an unsubscribe function.
|
|
661
|
+
*/
|
|
662
|
+
onChange(listener) {
|
|
663
|
+
this.changeListeners.add(listener);
|
|
664
|
+
return () => {
|
|
665
|
+
this.changeListeners.delete(listener);
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
notifyChange() {
|
|
669
|
+
for (const l of this.changeListeners) {
|
|
670
|
+
try {
|
|
671
|
+
l();
|
|
672
|
+
} catch (err) {
|
|
673
|
+
console.warn("[shipeasy] onChange listener threw:", String(err));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
557
677
|
startPoll() {
|
|
558
678
|
this.timer = setInterval(() => {
|
|
559
|
-
this.fetchAll().catch(
|
|
679
|
+
this.fetchAll(true).catch(
|
|
560
680
|
(err) => console.warn("[shipeasy] background poll failed:", String(err))
|
|
561
681
|
);
|
|
562
682
|
}, this.pollInterval * 1e3);
|
|
563
683
|
}
|
|
564
|
-
async fetchAll() {
|
|
565
|
-
const [
|
|
684
|
+
async fetchAll(fromPoll = false) {
|
|
685
|
+
const [flagsRes, expsChanged] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
|
|
686
|
+
const { interval, changed: flagsChanged } = flagsRes;
|
|
566
687
|
if (interval !== null && interval !== this.pollInterval) {
|
|
567
688
|
this.pollInterval = interval;
|
|
568
689
|
if (this.timer !== null) {
|
|
@@ -570,41 +691,72 @@ var FlagsClient = class {
|
|
|
570
691
|
this.startPoll();
|
|
571
692
|
}
|
|
572
693
|
}
|
|
694
|
+
if (fromPoll && (flagsChanged || expsChanged)) this.notifyChange();
|
|
573
695
|
}
|
|
574
696
|
async fetchFlags() {
|
|
575
697
|
const headers = { "X-SDK-Key": this.apiKey };
|
|
576
698
|
if (this.flagsEtag) headers["If-None-Match"] = this.flagsEtag;
|
|
577
699
|
const res = await globalThis.fetch(`${this.baseUrl}/sdk/flags?env=${this.env}`, { headers });
|
|
578
700
|
const interval = Number(res.headers.get("X-Poll-Interval") ?? "30") || 30;
|
|
579
|
-
if (res.status === 304) return interval;
|
|
701
|
+
if (res.status === 304) return { interval, changed: false };
|
|
580
702
|
if (!res.ok) throw new Error(`/sdk/flags returned ${res.status}`);
|
|
581
703
|
const etag = res.headers.get("ETag");
|
|
582
704
|
if (etag) this.flagsEtag = etag;
|
|
583
705
|
this.flagsBlob = await res.json();
|
|
584
|
-
return interval;
|
|
706
|
+
return { interval, changed: true };
|
|
585
707
|
}
|
|
586
708
|
async fetchExps() {
|
|
587
709
|
const headers = { "X-SDK-Key": this.apiKey };
|
|
588
710
|
if (this.expsEtag) headers["If-None-Match"] = this.expsEtag;
|
|
589
711
|
const res = await globalThis.fetch(`${this.baseUrl}/sdk/experiments`, { headers });
|
|
590
|
-
if (res.status === 304) return;
|
|
712
|
+
if (res.status === 304) return false;
|
|
591
713
|
if (!res.ok) throw new Error(`/sdk/experiments returned ${res.status}`);
|
|
592
714
|
const etag = res.headers.get("ETag");
|
|
593
715
|
if (etag) this.expsEtag = etag;
|
|
594
716
|
this.expsBlob = await res.json();
|
|
717
|
+
return true;
|
|
595
718
|
}
|
|
596
|
-
|
|
719
|
+
/**
|
|
720
|
+
* Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). The
|
|
721
|
+
* reason is computed entirely at this boundary — the canonical eval
|
|
722
|
+
* (evalGateInternal) is untouched. A local override short-circuits BEFORE
|
|
723
|
+
* telemetry, exactly like getFlag's override path; otherwise exactly one
|
|
724
|
+
* "gate" beacon is emitted.
|
|
725
|
+
*/
|
|
726
|
+
getFlagDetail(name, user) {
|
|
727
|
+
const ov = this.flagOverrides.get(name);
|
|
728
|
+
if (ov !== void 0) return { value: ov, reason: "OVERRIDE" };
|
|
597
729
|
this.telemetry.emit("gate", name);
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
730
|
+
if (!this.flagsBlob) return { value: false, reason: "CLIENT_NOT_READY" };
|
|
731
|
+
const gate = this.flagsBlob.gates[name];
|
|
732
|
+
if (!gate) return { value: false, reason: "FLAG_NOT_FOUND" };
|
|
733
|
+
if (isEnabled(gate.killswitch) || !isEnabled(gate.enabled)) {
|
|
734
|
+
return { value: false, reason: "OFF" };
|
|
735
|
+
}
|
|
736
|
+
const value = evalGateInternal(gate, user);
|
|
737
|
+
return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
|
|
741
|
+
* evaluated (client not initialized or flag not found) — never for a gate that
|
|
742
|
+
* legitimately evaluates to false. Plain `getFlag(name, user)` keeps returning
|
|
743
|
+
* false for a missing flag.
|
|
744
|
+
*/
|
|
745
|
+
getFlag(name, user, defaultValue = false) {
|
|
746
|
+
const d = this.getFlagDetail(name, user);
|
|
747
|
+
if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
|
|
748
|
+
return d.value;
|
|
601
749
|
}
|
|
602
|
-
getConfig(name,
|
|
750
|
+
getConfig(name, decodeOrOpts) {
|
|
603
751
|
this.telemetry.emit("config", name);
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
752
|
+
const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
|
|
753
|
+
const has = this.configOverrides.has(name);
|
|
754
|
+
const raw = has ? this.configOverrides.get(name) : this.flagsBlob?.configs[name]?.value;
|
|
755
|
+
if (raw === void 0 && !has) {
|
|
756
|
+
return "defaultValue" in opts ? opts.defaultValue : void 0;
|
|
757
|
+
}
|
|
758
|
+
if (!opts.decode) return raw;
|
|
759
|
+
return opts.decode(raw);
|
|
608
760
|
}
|
|
609
761
|
getExperiment(name, user, defaultParams, decode) {
|
|
610
762
|
this.telemetry.emit("experiment", name);
|
|
@@ -613,6 +765,16 @@ var FlagsClient = class {
|
|
|
613
765
|
group: "control",
|
|
614
766
|
params: defaultParams
|
|
615
767
|
};
|
|
768
|
+
const ov = this.experimentOverrides.get(name);
|
|
769
|
+
if (ov) {
|
|
770
|
+
if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
|
|
771
|
+
try {
|
|
772
|
+
return { inExperiment: true, group: ov.group, params: decode(ov.params) };
|
|
773
|
+
} catch (err) {
|
|
774
|
+
console.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
|
|
775
|
+
return notIn;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
616
778
|
if (!this.flagsBlob || !this.expsBlob) return notIn;
|
|
617
779
|
const exp = this.expsBlob.experiments[name];
|
|
618
780
|
if (!exp || exp.status !== "running") return notIn;
|
|
@@ -650,6 +812,7 @@ var FlagsClient = class {
|
|
|
650
812
|
return notIn;
|
|
651
813
|
}
|
|
652
814
|
track(userId, eventName, props) {
|
|
815
|
+
if (this.testMode) return;
|
|
653
816
|
const body = JSON.stringify({
|
|
654
817
|
events: [
|
|
655
818
|
{
|
|
@@ -984,11 +1147,15 @@ var flags = {
|
|
|
984
1147
|
destroy() {
|
|
985
1148
|
_server?.destroy();
|
|
986
1149
|
},
|
|
987
|
-
get(name, user) {
|
|
988
|
-
return _server?.getFlag(name, user) ??
|
|
1150
|
+
get(name, user, defaultValue = false) {
|
|
1151
|
+
return _server?.getFlag(name, user, defaultValue) ?? defaultValue;
|
|
1152
|
+
},
|
|
1153
|
+
/** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
|
|
1154
|
+
getDetail(name, user) {
|
|
1155
|
+
return _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" };
|
|
989
1156
|
},
|
|
990
|
-
getConfig(name,
|
|
991
|
-
return _server?.getConfig(name,
|
|
1157
|
+
getConfig(name, decodeOrOpts) {
|
|
1158
|
+
return _server?.getConfig(name, decodeOrOpts);
|
|
992
1159
|
},
|
|
993
1160
|
getExperiment(name, user, defaultParams, decode) {
|
|
994
1161
|
return _server?.getExperiment(name, user, defaultParams, decode) ?? {
|
|
@@ -1039,7 +1206,9 @@ var see = Object.assign(
|
|
|
1039
1206
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1040
1207
|
0 && (module.exports = {
|
|
1041
1208
|
ANON_ID_COOKIE,
|
|
1209
|
+
FLAG_REASONS,
|
|
1042
1210
|
FlagsClient,
|
|
1211
|
+
_murmur3ForTests,
|
|
1043
1212
|
_resetShipeasyServerForTests,
|
|
1044
1213
|
configureShipeasyServer,
|
|
1045
1214
|
fetchLabelsForSSR,
|
package/dist/server/index.mjs
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
1
8
|
// src/server/index.ts
|
|
2
9
|
import { AsyncLocalStorage } from "async_hooks";
|
|
3
10
|
|
|
@@ -315,6 +322,9 @@ var SeeLimiter = class {
|
|
|
315
322
|
var version = "4.0.0";
|
|
316
323
|
var C1 = 3432918353;
|
|
317
324
|
var C2 = 461845907;
|
|
325
|
+
function _murmur3ForTests(key) {
|
|
326
|
+
return murmur3(key);
|
|
327
|
+
}
|
|
318
328
|
function murmur3(key) {
|
|
319
329
|
const bytes = new TextEncoder().encode(key);
|
|
320
330
|
const len = bytes.length;
|
|
@@ -355,6 +365,14 @@ function murmur3(key) {
|
|
|
355
365
|
h1 ^= h1 >>> 16;
|
|
356
366
|
return h1 >>> 0;
|
|
357
367
|
}
|
|
368
|
+
var FLAG_REASONS = [
|
|
369
|
+
"CLIENT_NOT_READY",
|
|
370
|
+
"FLAG_NOT_FOUND",
|
|
371
|
+
"OFF",
|
|
372
|
+
"OVERRIDE",
|
|
373
|
+
"RULE_MATCH",
|
|
374
|
+
"DEFAULT"
|
|
375
|
+
];
|
|
358
376
|
function isEnabled(v) {
|
|
359
377
|
return v === 1 || v === true;
|
|
360
378
|
}
|
|
@@ -462,7 +480,7 @@ function parseOverrides(rawUrl) {
|
|
|
462
480
|
}
|
|
463
481
|
return { gates, configs, experiments };
|
|
464
482
|
}
|
|
465
|
-
var FlagsClient = class {
|
|
483
|
+
var FlagsClient = class _FlagsClient {
|
|
466
484
|
apiKey;
|
|
467
485
|
baseUrl;
|
|
468
486
|
env;
|
|
@@ -475,47 +493,155 @@ var FlagsClient = class {
|
|
|
475
493
|
pollInterval = 30;
|
|
476
494
|
timer = null;
|
|
477
495
|
initialized = false;
|
|
496
|
+
// Test mode: built by `FlagsClient.forTesting()`. When set, init()/initOnce()
|
|
497
|
+
// never fetch, track() is a no-op, and telemetry is off — the client is a
|
|
498
|
+
// fully self-contained, network-free seam for unit tests.
|
|
499
|
+
testMode;
|
|
500
|
+
// Programmatic overrides (Statsig-style). Set on any client via
|
|
501
|
+
// overrideFlag/overrideConfig/overrideExperiment; they win over the fetched
|
|
502
|
+
// blob in getFlag/getConfig/getExperiment. Cleared by clearOverrides().
|
|
503
|
+
flagOverrides = /* @__PURE__ */ new Map();
|
|
504
|
+
configOverrides = /* @__PURE__ */ new Map();
|
|
505
|
+
experimentOverrides = /* @__PURE__ */ new Map();
|
|
506
|
+
// Change listeners fired after a background poll returns NEW data (200, not
|
|
507
|
+
// 304). Never fired in testMode/offline (no polling happens there).
|
|
508
|
+
changeListeners = /* @__PURE__ */ new Set();
|
|
478
509
|
constructor(opts) {
|
|
479
510
|
this.apiKey = opts.apiKey;
|
|
480
511
|
this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
|
|
481
512
|
this.env = opts.env ?? "prod";
|
|
513
|
+
this.testMode = opts.testMode === true;
|
|
482
514
|
this.telemetry = new Telemetry({
|
|
483
515
|
endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
|
|
484
516
|
sdkKey: this.apiKey,
|
|
485
517
|
side: "server",
|
|
486
518
|
env: this.env,
|
|
487
|
-
|
|
519
|
+
// Test mode never talks to the network — telemetry off regardless of opt.
|
|
520
|
+
disabled: this.testMode || opts.disableTelemetry
|
|
488
521
|
});
|
|
489
|
-
if (opts.initialBlob) {
|
|
490
|
-
this.flagsBlob = opts.initialBlob;
|
|
522
|
+
if (opts.initialBlob || this.testMode) {
|
|
523
|
+
this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
|
|
524
|
+
this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
|
|
491
525
|
this.initialized = true;
|
|
492
526
|
}
|
|
493
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* Build a no-network, immediately-usable client for tests (Statsig-style).
|
|
530
|
+
* init()/initOnce() are no-ops (never fetch), track() is a no-op, telemetry
|
|
531
|
+
* is disabled, and the client is already "initialized" — seed every entity
|
|
532
|
+
* with overrideFlag/overrideConfig/overrideExperiment. No SDK key required.
|
|
533
|
+
*
|
|
534
|
+
* ```ts
|
|
535
|
+
* const client = FlagsClient.forTesting();
|
|
536
|
+
* client.overrideFlag("new_checkout", true);
|
|
537
|
+
* client.getFlag("new_checkout", { user_id: "u1" }); // true
|
|
538
|
+
* ```
|
|
539
|
+
*/
|
|
540
|
+
static forTesting(opts) {
|
|
541
|
+
return new _FlagsClient({ apiKey: "", ...opts, testMode: true });
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Build a fully OFFLINE client from a pre-captured snapshot — no network ever.
|
|
545
|
+
* Reuses the test-mode plumbing (init()/initOnce()/track() are no-ops,
|
|
546
|
+
* telemetry off) but, unlike forTesting(), seeds the REAL flags + experiments
|
|
547
|
+
* blobs so evaluations run the canonical eval against the snapshot. Local
|
|
548
|
+
* overrides still apply on top.
|
|
549
|
+
*
|
|
550
|
+
* Snapshot shape mirrors the wire bodies:
|
|
551
|
+
* `{ flags: <GET /sdk/flags body>, experiments: <GET /sdk/experiments body> }`.
|
|
552
|
+
*/
|
|
553
|
+
static fromSnapshot(snapshot) {
|
|
554
|
+
const client = new _FlagsClient({ apiKey: "", testMode: true });
|
|
555
|
+
client.flagsBlob = snapshot.flags;
|
|
556
|
+
client.expsBlob = snapshot.experiments;
|
|
557
|
+
client.initialized = true;
|
|
558
|
+
return client;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Build a fully OFFLINE client from a snapshot JSON file on disk (Node only —
|
|
562
|
+
* not available in the browser entrypoint). The file must contain
|
|
563
|
+
* `{ "flags": <GET /sdk/flags body>, "experiments": <GET /sdk/experiments body> }`.
|
|
564
|
+
* See {@link FlagsClient.fromSnapshot}.
|
|
565
|
+
*/
|
|
566
|
+
static fromFile(path) {
|
|
567
|
+
const fs = __require("fs");
|
|
568
|
+
const raw = fs.readFileSync(path, "utf8");
|
|
569
|
+
const snapshot = JSON.parse(raw);
|
|
570
|
+
return _FlagsClient.fromSnapshot(snapshot);
|
|
571
|
+
}
|
|
494
572
|
async init() {
|
|
573
|
+
if (this.testMode) {
|
|
574
|
+
this.initialized = true;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
495
577
|
await this.fetchAll();
|
|
496
578
|
this.initialized = true;
|
|
497
579
|
this.startPoll();
|
|
498
580
|
}
|
|
499
581
|
async initOnce() {
|
|
500
|
-
if (this.initialized) return;
|
|
582
|
+
if (this.testMode || this.initialized) return;
|
|
501
583
|
await this.fetchAll();
|
|
502
584
|
this.initialized = true;
|
|
503
585
|
}
|
|
586
|
+
// ---- Local overrides (Statsig-style) ----
|
|
587
|
+
/** Force `getFlag(name, …)` to return `value`, ignoring the fetched gate. */
|
|
588
|
+
overrideFlag(name, value) {
|
|
589
|
+
this.flagOverrides.set(name, value);
|
|
590
|
+
}
|
|
591
|
+
/** Force `getConfig(name)` to return `value`, ignoring the fetched config. */
|
|
592
|
+
overrideConfig(name, value) {
|
|
593
|
+
this.configOverrides.set(name, value);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
|
|
597
|
+
* ignoring allocation, holdouts, and targeting.
|
|
598
|
+
*/
|
|
599
|
+
overrideExperiment(name, group, params) {
|
|
600
|
+
this.experimentOverrides.set(name, { group, params });
|
|
601
|
+
}
|
|
602
|
+
/** Remove every programmatic override set via the override* methods. */
|
|
603
|
+
clearOverrides() {
|
|
604
|
+
this.flagOverrides.clear();
|
|
605
|
+
this.configOverrides.clear();
|
|
606
|
+
this.experimentOverrides.clear();
|
|
607
|
+
}
|
|
504
608
|
destroy() {
|
|
505
609
|
if (this.timer !== null) {
|
|
506
610
|
clearInterval(this.timer);
|
|
507
611
|
this.timer = null;
|
|
508
612
|
}
|
|
509
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Subscribe to data-change notifications. The listener fires after a
|
|
616
|
+
* background poll fetch returns NEW data (200, not 304) — i.e. after the
|
|
617
|
+
* cached blob is updated. Never fires in testMode/offline (no polling).
|
|
618
|
+
* Returns an unsubscribe function.
|
|
619
|
+
*/
|
|
620
|
+
onChange(listener) {
|
|
621
|
+
this.changeListeners.add(listener);
|
|
622
|
+
return () => {
|
|
623
|
+
this.changeListeners.delete(listener);
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
notifyChange() {
|
|
627
|
+
for (const l of this.changeListeners) {
|
|
628
|
+
try {
|
|
629
|
+
l();
|
|
630
|
+
} catch (err) {
|
|
631
|
+
console.warn("[shipeasy] onChange listener threw:", String(err));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
510
635
|
startPoll() {
|
|
511
636
|
this.timer = setInterval(() => {
|
|
512
|
-
this.fetchAll().catch(
|
|
637
|
+
this.fetchAll(true).catch(
|
|
513
638
|
(err) => console.warn("[shipeasy] background poll failed:", String(err))
|
|
514
639
|
);
|
|
515
640
|
}, this.pollInterval * 1e3);
|
|
516
641
|
}
|
|
517
|
-
async fetchAll() {
|
|
518
|
-
const [
|
|
642
|
+
async fetchAll(fromPoll = false) {
|
|
643
|
+
const [flagsRes, expsChanged] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
|
|
644
|
+
const { interval, changed: flagsChanged } = flagsRes;
|
|
519
645
|
if (interval !== null && interval !== this.pollInterval) {
|
|
520
646
|
this.pollInterval = interval;
|
|
521
647
|
if (this.timer !== null) {
|
|
@@ -523,41 +649,72 @@ var FlagsClient = class {
|
|
|
523
649
|
this.startPoll();
|
|
524
650
|
}
|
|
525
651
|
}
|
|
652
|
+
if (fromPoll && (flagsChanged || expsChanged)) this.notifyChange();
|
|
526
653
|
}
|
|
527
654
|
async fetchFlags() {
|
|
528
655
|
const headers = { "X-SDK-Key": this.apiKey };
|
|
529
656
|
if (this.flagsEtag) headers["If-None-Match"] = this.flagsEtag;
|
|
530
657
|
const res = await globalThis.fetch(`${this.baseUrl}/sdk/flags?env=${this.env}`, { headers });
|
|
531
658
|
const interval = Number(res.headers.get("X-Poll-Interval") ?? "30") || 30;
|
|
532
|
-
if (res.status === 304) return interval;
|
|
659
|
+
if (res.status === 304) return { interval, changed: false };
|
|
533
660
|
if (!res.ok) throw new Error(`/sdk/flags returned ${res.status}`);
|
|
534
661
|
const etag = res.headers.get("ETag");
|
|
535
662
|
if (etag) this.flagsEtag = etag;
|
|
536
663
|
this.flagsBlob = await res.json();
|
|
537
|
-
return interval;
|
|
664
|
+
return { interval, changed: true };
|
|
538
665
|
}
|
|
539
666
|
async fetchExps() {
|
|
540
667
|
const headers = { "X-SDK-Key": this.apiKey };
|
|
541
668
|
if (this.expsEtag) headers["If-None-Match"] = this.expsEtag;
|
|
542
669
|
const res = await globalThis.fetch(`${this.baseUrl}/sdk/experiments`, { headers });
|
|
543
|
-
if (res.status === 304) return;
|
|
670
|
+
if (res.status === 304) return false;
|
|
544
671
|
if (!res.ok) throw new Error(`/sdk/experiments returned ${res.status}`);
|
|
545
672
|
const etag = res.headers.get("ETag");
|
|
546
673
|
if (etag) this.expsEtag = etag;
|
|
547
674
|
this.expsBlob = await res.json();
|
|
675
|
+
return true;
|
|
548
676
|
}
|
|
549
|
-
|
|
677
|
+
/**
|
|
678
|
+
* Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). The
|
|
679
|
+
* reason is computed entirely at this boundary — the canonical eval
|
|
680
|
+
* (evalGateInternal) is untouched. A local override short-circuits BEFORE
|
|
681
|
+
* telemetry, exactly like getFlag's override path; otherwise exactly one
|
|
682
|
+
* "gate" beacon is emitted.
|
|
683
|
+
*/
|
|
684
|
+
getFlagDetail(name, user) {
|
|
685
|
+
const ov = this.flagOverrides.get(name);
|
|
686
|
+
if (ov !== void 0) return { value: ov, reason: "OVERRIDE" };
|
|
550
687
|
this.telemetry.emit("gate", name);
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
688
|
+
if (!this.flagsBlob) return { value: false, reason: "CLIENT_NOT_READY" };
|
|
689
|
+
const gate = this.flagsBlob.gates[name];
|
|
690
|
+
if (!gate) return { value: false, reason: "FLAG_NOT_FOUND" };
|
|
691
|
+
if (isEnabled(gate.killswitch) || !isEnabled(gate.enabled)) {
|
|
692
|
+
return { value: false, reason: "OFF" };
|
|
693
|
+
}
|
|
694
|
+
const value = evalGateInternal(gate, user);
|
|
695
|
+
return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
|
|
699
|
+
* evaluated (client not initialized or flag not found) — never for a gate that
|
|
700
|
+
* legitimately evaluates to false. Plain `getFlag(name, user)` keeps returning
|
|
701
|
+
* false for a missing flag.
|
|
702
|
+
*/
|
|
703
|
+
getFlag(name, user, defaultValue = false) {
|
|
704
|
+
const d = this.getFlagDetail(name, user);
|
|
705
|
+
if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
|
|
706
|
+
return d.value;
|
|
554
707
|
}
|
|
555
|
-
getConfig(name,
|
|
708
|
+
getConfig(name, decodeOrOpts) {
|
|
556
709
|
this.telemetry.emit("config", name);
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
710
|
+
const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
|
|
711
|
+
const has = this.configOverrides.has(name);
|
|
712
|
+
const raw = has ? this.configOverrides.get(name) : this.flagsBlob?.configs[name]?.value;
|
|
713
|
+
if (raw === void 0 && !has) {
|
|
714
|
+
return "defaultValue" in opts ? opts.defaultValue : void 0;
|
|
715
|
+
}
|
|
716
|
+
if (!opts.decode) return raw;
|
|
717
|
+
return opts.decode(raw);
|
|
561
718
|
}
|
|
562
719
|
getExperiment(name, user, defaultParams, decode) {
|
|
563
720
|
this.telemetry.emit("experiment", name);
|
|
@@ -566,6 +723,16 @@ var FlagsClient = class {
|
|
|
566
723
|
group: "control",
|
|
567
724
|
params: defaultParams
|
|
568
725
|
};
|
|
726
|
+
const ov = this.experimentOverrides.get(name);
|
|
727
|
+
if (ov) {
|
|
728
|
+
if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
|
|
729
|
+
try {
|
|
730
|
+
return { inExperiment: true, group: ov.group, params: decode(ov.params) };
|
|
731
|
+
} catch (err) {
|
|
732
|
+
console.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
|
|
733
|
+
return notIn;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
569
736
|
if (!this.flagsBlob || !this.expsBlob) return notIn;
|
|
570
737
|
const exp = this.expsBlob.experiments[name];
|
|
571
738
|
if (!exp || exp.status !== "running") return notIn;
|
|
@@ -603,6 +770,7 @@ var FlagsClient = class {
|
|
|
603
770
|
return notIn;
|
|
604
771
|
}
|
|
605
772
|
track(userId, eventName, props) {
|
|
773
|
+
if (this.testMode) return;
|
|
606
774
|
const body = JSON.stringify({
|
|
607
775
|
events: [
|
|
608
776
|
{
|
|
@@ -937,11 +1105,15 @@ var flags = {
|
|
|
937
1105
|
destroy() {
|
|
938
1106
|
_server?.destroy();
|
|
939
1107
|
},
|
|
940
|
-
get(name, user) {
|
|
941
|
-
return _server?.getFlag(name, user) ??
|
|
1108
|
+
get(name, user, defaultValue = false) {
|
|
1109
|
+
return _server?.getFlag(name, user, defaultValue) ?? defaultValue;
|
|
1110
|
+
},
|
|
1111
|
+
/** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
|
|
1112
|
+
getDetail(name, user) {
|
|
1113
|
+
return _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" };
|
|
942
1114
|
},
|
|
943
|
-
getConfig(name,
|
|
944
|
-
return _server?.getConfig(name,
|
|
1115
|
+
getConfig(name, decodeOrOpts) {
|
|
1116
|
+
return _server?.getConfig(name, decodeOrOpts);
|
|
945
1117
|
},
|
|
946
1118
|
getExperiment(name, user, defaultParams, decode) {
|
|
947
1119
|
return _server?.getExperiment(name, user, defaultParams, decode) ?? {
|
|
@@ -991,7 +1163,9 @@ var see = Object.assign(
|
|
|
991
1163
|
);
|
|
992
1164
|
export {
|
|
993
1165
|
ANON_ID_COOKIE,
|
|
1166
|
+
FLAG_REASONS,
|
|
994
1167
|
FlagsClient,
|
|
1168
|
+
_murmur3ForTests,
|
|
995
1169
|
_resetShipeasyServerForTests,
|
|
996
1170
|
configureShipeasyServer,
|
|
997
1171
|
fetchLabelsForSSR,
|