@shipeasy/sdk 4.5.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 +174 -13
- package/dist/client/index.d.mts +110 -17
- package/dist/client/index.d.ts +110 -17
- package/dist/client/index.js +170 -73
- package/dist/client/index.mjs +169 -73
- package/dist/server/index.d.mts +170 -16
- package/dist/server/index.d.ts +170 -16
- package/dist/server/index.js +223 -48
- package/dist/server/index.mjs +228 -48
- package/package.json +1 -1
package/dist/client/index.mjs
CHANGED
|
@@ -78,25 +78,29 @@ function causesThe(subject) {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
function violation(name) {
|
|
81
|
-
|
|
82
|
-
__seViolation: true,
|
|
83
|
-
violationName: String(name),
|
|
84
|
-
...msg !== void 0 ? { violationMessage: msg } : {},
|
|
85
|
-
message(m) {
|
|
86
|
-
return make(String(m));
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
return make();
|
|
81
|
+
return { __seViolation: true, violationName: String(name) };
|
|
90
82
|
}
|
|
91
83
|
function isViolation(p) {
|
|
92
84
|
return typeof p === "object" && p !== null && p.__seViolation === true;
|
|
93
85
|
}
|
|
94
86
|
var EXPECTED_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:see-expected");
|
|
95
|
-
function
|
|
87
|
+
function readExpectedMark(err) {
|
|
88
|
+
if (typeof err !== "object" || err === null) return void 0;
|
|
89
|
+
const v = err[EXPECTED_SYM];
|
|
90
|
+
return v !== void 0 && v !== null && typeof v === "object" ? v : void 0;
|
|
91
|
+
}
|
|
92
|
+
function markExpected(err, because, extras) {
|
|
96
93
|
if (typeof err !== "object" || err === null) return;
|
|
94
|
+
const prev = readExpectedMark(err);
|
|
95
|
+
const clean = sanitizeExtras(extras);
|
|
96
|
+
const merged = prev?.extras || clean ? { ...prev?.extras, ...clean } : void 0;
|
|
97
|
+
const mark = {
|
|
98
|
+
because: String(because),
|
|
99
|
+
...merged ? { extras: merged } : {}
|
|
100
|
+
};
|
|
97
101
|
try {
|
|
98
102
|
Object.defineProperty(err, EXPECTED_SYM, {
|
|
99
|
-
value:
|
|
103
|
+
value: mark,
|
|
100
104
|
enumerable: false,
|
|
101
105
|
configurable: true
|
|
102
106
|
});
|
|
@@ -177,7 +181,7 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
|
|
|
177
181
|
let kind;
|
|
178
182
|
if (isViolation(problem)) {
|
|
179
183
|
errorType = problem.violationName;
|
|
180
|
-
message = problem.
|
|
184
|
+
message = problem.violationName;
|
|
181
185
|
stack = captureCallsiteStack();
|
|
182
186
|
kind = kindOverride ?? "violation";
|
|
183
187
|
} else if (problem instanceof Error) {
|
|
@@ -260,19 +264,21 @@ function startSeeChain(getProblem, dispatch) {
|
|
|
260
264
|
return { causes_the: start, causesThe: start };
|
|
261
265
|
}
|
|
262
266
|
function startSeeViolationChain(name, dispatch) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
267
|
+
return startSeeChain(() => violation(name), dispatch);
|
|
268
|
+
}
|
|
269
|
+
function startControlFlowChain(err) {
|
|
270
|
+
return {
|
|
271
|
+
because(reason) {
|
|
272
|
+
markExpected(err, reason);
|
|
273
|
+
const tail = {
|
|
274
|
+
extras(x) {
|
|
275
|
+
markExpected(err, reason, x);
|
|
276
|
+
return tail;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
return tail;
|
|
273
280
|
}
|
|
274
281
|
};
|
|
275
|
-
return chain;
|
|
276
282
|
}
|
|
277
283
|
function topStackLine(stack) {
|
|
278
284
|
if (!stack) return "";
|
|
@@ -304,6 +310,14 @@ var SeeLimiter = class {
|
|
|
304
310
|
|
|
305
311
|
// src/client/index.ts
|
|
306
312
|
var version = "4.0.0";
|
|
313
|
+
var FLAG_REASONS = [
|
|
314
|
+
"CLIENT_NOT_READY",
|
|
315
|
+
"FLAG_NOT_FOUND",
|
|
316
|
+
"OFF",
|
|
317
|
+
"OVERRIDE",
|
|
318
|
+
"RULE_MATCH",
|
|
319
|
+
"DEFAULT"
|
|
320
|
+
];
|
|
307
321
|
var FLUSH_INTERVAL_MS = 5e3;
|
|
308
322
|
var MAX_BUFFER = 100;
|
|
309
323
|
var ANON_ID_KEY = "__se_anon_id";
|
|
@@ -527,39 +541,11 @@ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignore
|
|
|
527
541
|
}
|
|
528
542
|
}
|
|
529
543
|
if (groups.errors) {
|
|
530
|
-
const origOnError = window.onerror;
|
|
531
|
-
window.onerror = (msg, source, lineno, _colno, err) => {
|
|
532
|
-
if (!isExpected(err)) {
|
|
533
|
-
const problem = err ?? (typeof msg === "string" && msg ? msg : "Unknown error");
|
|
534
|
-
reportSee(
|
|
535
|
-
problem,
|
|
536
|
-
causesThe("page").to("hit an unhandled error"),
|
|
537
|
-
{
|
|
538
|
-
source: typeof source === "string" ? source : void 0,
|
|
539
|
-
line: lineno ?? void 0
|
|
540
|
-
},
|
|
541
|
-
"uncaught"
|
|
542
|
-
);
|
|
543
|
-
}
|
|
544
|
-
if (typeof origOnError === "function") return origOnError(msg, source, lineno, _colno, err);
|
|
545
|
-
return false;
|
|
546
|
-
};
|
|
547
|
-
window.addEventListener("unhandledrejection", (e) => {
|
|
548
|
-
const reason = e.reason;
|
|
549
|
-
if (isExpected(reason)) return;
|
|
550
|
-
reportSee(
|
|
551
|
-
reason ?? "Unhandled promise rejection",
|
|
552
|
-
causesThe("page").to("hit an unhandled promise rejection"),
|
|
553
|
-
void 0,
|
|
554
|
-
"unhandled_rejection"
|
|
555
|
-
);
|
|
556
|
-
});
|
|
557
544
|
const origFetch = window.fetch;
|
|
558
545
|
window.fetch = async function(...args) {
|
|
559
546
|
const startedAt = typeof performance !== "undefined" ? performance.now() : 0;
|
|
560
547
|
const url = typeof args[0] === "string" ? args[0] : args[0].toString();
|
|
561
548
|
const ignored = ignoreUrlPrefixes.some((p) => p && url.startsWith(p));
|
|
562
|
-
const bareUrl = url.split("?")[0].slice(0, 200);
|
|
563
549
|
let corr;
|
|
564
550
|
if (!ignored && sameOrigin(url) && typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
565
551
|
corr = crypto.randomUUID();
|
|
@@ -571,7 +557,7 @@ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignore
|
|
|
571
557
|
} catch (err) {
|
|
572
558
|
if (!ignored && !isExpected(err)) {
|
|
573
559
|
reportSee(
|
|
574
|
-
violation("NetworkError")
|
|
560
|
+
violation("NetworkError"),
|
|
575
561
|
causesThe(`request to ${endpointTemplate(url)}`).to("get no response"),
|
|
576
562
|
{ status: 0, url: url.slice(0, 200) },
|
|
577
563
|
"network"
|
|
@@ -582,7 +568,7 @@ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignore
|
|
|
582
568
|
if (!ignored && res.status >= 500) {
|
|
583
569
|
const elapsed = typeof performance !== "undefined" ? performance.now() - startedAt : 0;
|
|
584
570
|
reportSee(
|
|
585
|
-
violation("Http5xx")
|
|
571
|
+
violation("Http5xx"),
|
|
586
572
|
causesThe(`request to ${endpointTemplate(url)}`).to("fail with a server error"),
|
|
587
573
|
{ status: res.status, url: url.slice(0, 200), duration_ms: Math.round(elapsed) },
|
|
588
574
|
"network",
|
|
@@ -843,7 +829,7 @@ function readExperimentOverridesFromUrl() {
|
|
|
843
829
|
}
|
|
844
830
|
return out;
|
|
845
831
|
}
|
|
846
|
-
var FlagsClientBrowser = class {
|
|
832
|
+
var FlagsClientBrowser = class _FlagsClientBrowser {
|
|
847
833
|
sdkKey;
|
|
848
834
|
baseUrl;
|
|
849
835
|
autoGuardrails;
|
|
@@ -862,6 +848,16 @@ var FlagsClientBrowser = class {
|
|
|
862
848
|
// Monotonic counter so a later identify() always wins even if its /sdk/evaluate
|
|
863
849
|
// response races and lands before an earlier in-flight call's response.
|
|
864
850
|
identifySeq = 0;
|
|
851
|
+
// Test mode: built by `FlagsClientBrowser.forTesting()`. When set, identify()
|
|
852
|
+
// never fetches, track() is a no-op, telemetry is off, and the client is
|
|
853
|
+
// already ready with an empty eval result.
|
|
854
|
+
testMode;
|
|
855
|
+
// Programmatic overrides (Statsig-style). Set on any client via
|
|
856
|
+
// overrideFlag/overrideConfig/overrideExperiment; they win over BOTH the URL
|
|
857
|
+
// overrides and the fetched eval result. Cleared by clearOverrides().
|
|
858
|
+
flagOverrides = /* @__PURE__ */ new Map();
|
|
859
|
+
configOverrides = /* @__PURE__ */ new Map();
|
|
860
|
+
experimentOverrides = /* @__PURE__ */ new Map();
|
|
865
861
|
onOverrideChange = () => {
|
|
866
862
|
this.installBridge();
|
|
867
863
|
this.notify();
|
|
@@ -870,6 +866,7 @@ var FlagsClientBrowser = class {
|
|
|
870
866
|
this.sdkKey = opts.sdkKey;
|
|
871
867
|
this.baseUrl = (opts.baseUrl ?? "https://edge.shipeasy.dev").replace(/\/$/, "");
|
|
872
868
|
this.env = opts.env ?? "prod";
|
|
869
|
+
this.testMode = opts.testMode === true;
|
|
873
870
|
this.autoGuardrails = opts.autoGuardrails !== false;
|
|
874
871
|
this.autoCollectAlways = opts.autoCollectAlways === true;
|
|
875
872
|
const g = opts.autoGuardrailGroups ?? {};
|
|
@@ -885,11 +882,42 @@ var FlagsClientBrowser = class {
|
|
|
885
882
|
sdkKey: this.sdkKey,
|
|
886
883
|
side: "client",
|
|
887
884
|
env: this.env,
|
|
888
|
-
|
|
885
|
+
// Test mode never talks to the network — telemetry off regardless of opt.
|
|
886
|
+
disabled: this.testMode || opts.disableTelemetry
|
|
887
|
+
});
|
|
888
|
+
if (this.testMode) {
|
|
889
|
+
this.evalResult = { flags: {}, configs: {}, experiments: {}, killswitches: {} };
|
|
890
|
+
} else {
|
|
891
|
+
void this.buffer.flushPendingAlias();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Build a no-network, immediately-usable browser client for tests
|
|
896
|
+
* (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
|
|
897
|
+
* is a no-op, telemetry is disabled, and the client is already ready — seed
|
|
898
|
+
* every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
|
|
899
|
+
* key required.
|
|
900
|
+
*
|
|
901
|
+
* ```ts
|
|
902
|
+
* const client = FlagsClientBrowser.forTesting();
|
|
903
|
+
* client.overrideFlag("new_checkout", true);
|
|
904
|
+
* client.getFlag("new_checkout"); // true
|
|
905
|
+
* ```
|
|
906
|
+
*/
|
|
907
|
+
static forTesting(opts) {
|
|
908
|
+
return new _FlagsClientBrowser({
|
|
909
|
+
sdkKey: "",
|
|
910
|
+
autoGuardrails: false,
|
|
911
|
+
...opts,
|
|
912
|
+
testMode: true
|
|
889
913
|
});
|
|
890
|
-
void this.buffer.flushPendingAlias();
|
|
891
914
|
}
|
|
892
915
|
async identify(user) {
|
|
916
|
+
if (this.testMode) {
|
|
917
|
+
if (user.user_id !== void 0) this.userId = user.user_id;
|
|
918
|
+
this.notify();
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
893
921
|
const seq = ++this.identifySeq;
|
|
894
922
|
const prevUserId = this.userId;
|
|
895
923
|
if (user.user_id !== void 0) this.userId = user.user_id;
|
|
@@ -964,22 +992,75 @@ var FlagsClientBrowser = class {
|
|
|
964
992
|
initFromBootstrap(data) {
|
|
965
993
|
this.evalResult = data;
|
|
966
994
|
}
|
|
967
|
-
|
|
995
|
+
// ---- Local overrides (Statsig-style) ----
|
|
996
|
+
//
|
|
997
|
+
// Precedence (highest first): programmatic override (these methods) > URL
|
|
998
|
+
// override (?se_gate_/?se_config_/?se_exp_) > fetched eval result. A
|
|
999
|
+
// programmatic override is an explicit in-code decision, so it wins over the
|
|
1000
|
+
// ad-hoc URL/devtools overrides as well as the server's evaluation.
|
|
1001
|
+
/** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
|
|
1002
|
+
overrideFlag(name, value) {
|
|
1003
|
+
this.flagOverrides.set(name, value);
|
|
1004
|
+
}
|
|
1005
|
+
/** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
|
|
1006
|
+
overrideConfig(name, value) {
|
|
1007
|
+
this.configOverrides.set(name, value);
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
|
|
1011
|
+
* ignoring URL overrides, the eval result, and exposure logging.
|
|
1012
|
+
*/
|
|
1013
|
+
overrideExperiment(name, group, params) {
|
|
1014
|
+
this.experimentOverrides.set(name, { group, params });
|
|
1015
|
+
}
|
|
1016
|
+
/** Remove every programmatic override set via the override* methods. */
|
|
1017
|
+
clearOverrides() {
|
|
1018
|
+
this.flagOverrides.clear();
|
|
1019
|
+
this.configOverrides.clear();
|
|
1020
|
+
this.experimentOverrides.clear();
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
|
|
1024
|
+
* local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
|
|
1025
|
+
* telemetry; otherwise exactly one "gate" beacon is emitted. The server
|
|
1026
|
+
* pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
|
|
1027
|
+
* folds into DEFAULT here (the browser can't tell "disabled" from "rule
|
|
1028
|
+
* denied").
|
|
1029
|
+
*/
|
|
1030
|
+
getFlagDetail(name) {
|
|
1031
|
+
const pov = this.flagOverrides.get(name);
|
|
1032
|
+
if (pov !== void 0) return { value: pov, reason: "OVERRIDE" };
|
|
1033
|
+
const urlOv = readGateOverride(name);
|
|
1034
|
+
if (urlOv !== null) return { value: urlOv, reason: "OVERRIDE" };
|
|
968
1035
|
this.telemetry.emit("gate", name);
|
|
969
|
-
if (this.evalResult === null) return false;
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
return
|
|
1036
|
+
if (this.evalResult === null) return { value: false, reason: "CLIENT_NOT_READY" };
|
|
1037
|
+
if (!(name in this.evalResult.flags)) return { value: false, reason: "FLAG_NOT_FOUND" };
|
|
1038
|
+
const value = this.evalResult.flags[name] ?? false;
|
|
1039
|
+
return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
|
|
973
1040
|
}
|
|
974
|
-
|
|
1041
|
+
/**
|
|
1042
|
+
* Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
|
|
1043
|
+
* evaluated (not ready or flag not found) — never for a gate that legitimately
|
|
1044
|
+
* evaluates to false. Plain `getFlag(name)` keeps returning false for a
|
|
1045
|
+
* missing flag.
|
|
1046
|
+
*/
|
|
1047
|
+
getFlag(name, defaultValue = false) {
|
|
1048
|
+
const d = this.getFlagDetail(name);
|
|
1049
|
+
if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
|
|
1050
|
+
return d.value;
|
|
1051
|
+
}
|
|
1052
|
+
getConfig(name, decodeOrOpts) {
|
|
975
1053
|
this.telemetry.emit("config", name);
|
|
976
|
-
|
|
977
|
-
const
|
|
978
|
-
const
|
|
979
|
-
if (
|
|
980
|
-
|
|
1054
|
+
const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
|
|
1055
|
+
const fallback = "defaultValue" in opts ? opts.defaultValue : void 0;
|
|
1056
|
+
const hasProgrammatic = this.configOverrides.has(name);
|
|
1057
|
+
if (!hasProgrammatic && this.evalResult === null) return fallback;
|
|
1058
|
+
const urlOv = hasProgrammatic ? void 0 : readConfigOverride(name);
|
|
1059
|
+
const raw = hasProgrammatic ? this.configOverrides.get(name) : urlOv !== void 0 ? urlOv : this.evalResult?.configs?.[name];
|
|
1060
|
+
if (raw === void 0) return fallback;
|
|
1061
|
+
if (!opts.decode) return raw;
|
|
981
1062
|
try {
|
|
982
|
-
return decode(raw);
|
|
1063
|
+
return opts.decode(raw);
|
|
983
1064
|
} catch (err) {
|
|
984
1065
|
console.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
|
|
985
1066
|
return void 0;
|
|
@@ -992,6 +1073,12 @@ var FlagsClientBrowser = class {
|
|
|
992
1073
|
group: "control",
|
|
993
1074
|
params: defaultParams
|
|
994
1075
|
};
|
|
1076
|
+
const pov = this.experimentOverrides.get(name);
|
|
1077
|
+
if (pov) {
|
|
1078
|
+
const variantParams = variants?.[pov.group];
|
|
1079
|
+
const params = { ...defaultParams, ...pov.params, ...variantParams ?? {} };
|
|
1080
|
+
return { inExperiment: true, group: pov.group, params };
|
|
1081
|
+
}
|
|
995
1082
|
const ov = readExpOverride(name);
|
|
996
1083
|
if (ov !== null) {
|
|
997
1084
|
const variantParams = variants?.[ov];
|
|
@@ -1043,6 +1130,7 @@ var FlagsClientBrowser = class {
|
|
|
1043
1130
|
return bridge;
|
|
1044
1131
|
}
|
|
1045
1132
|
track(eventName, props) {
|
|
1133
|
+
if (this.testMode) return;
|
|
1046
1134
|
this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
|
|
1047
1135
|
}
|
|
1048
1136
|
/**
|
|
@@ -1261,12 +1349,19 @@ var flags = {
|
|
|
1261
1349
|
* Everything else gates on _mountedAndReady to prevent hydration mismatches on
|
|
1262
1350
|
* force-static pages where SSR has no flag data.
|
|
1263
1351
|
*/
|
|
1264
|
-
get(name) {
|
|
1352
|
+
get(name, defaultValue = false) {
|
|
1265
1353
|
const bs = getBootstrap();
|
|
1266
1354
|
if (bs !== null && name in bs.flags) return bs.flags[name];
|
|
1267
|
-
if (!_mountedAndReady) return
|
|
1268
|
-
if (_client) return _client.getFlag(name);
|
|
1269
|
-
return readGateOverride(name) ??
|
|
1355
|
+
if (!_mountedAndReady) return defaultValue;
|
|
1356
|
+
if (_client) return _client.getFlag(name, defaultValue);
|
|
1357
|
+
return readGateOverride(name) ?? defaultValue;
|
|
1358
|
+
},
|
|
1359
|
+
/** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
|
|
1360
|
+
getDetail(name) {
|
|
1361
|
+
if (_client) return _client.getFlagDetail(name);
|
|
1362
|
+
const ov = readGateOverride(name);
|
|
1363
|
+
if (ov !== null) return { value: ov, reason: "OVERRIDE" };
|
|
1364
|
+
return { value: false, reason: "CLIENT_NOT_READY" };
|
|
1270
1365
|
},
|
|
1271
1366
|
getConfig(name, decode) {
|
|
1272
1367
|
const bs = getBootstrap();
|
|
@@ -1361,7 +1456,7 @@ var see = Object.assign(
|
|
|
1361
1456
|
(problem) => startSeeChain(() => problem, dispatchSee),
|
|
1362
1457
|
{
|
|
1363
1458
|
Violation: (name) => startSeeViolationChain(name, dispatchSee),
|
|
1364
|
-
ControlFlowException:
|
|
1459
|
+
ControlFlowException: (err) => startControlFlowChain(err)
|
|
1365
1460
|
}
|
|
1366
1461
|
);
|
|
1367
1462
|
var LABEL_MARKER_START = "\uFFF9";
|
|
@@ -1597,6 +1692,7 @@ var i18n = {
|
|
|
1597
1692
|
}
|
|
1598
1693
|
};
|
|
1599
1694
|
export {
|
|
1695
|
+
FLAG_REASONS,
|
|
1600
1696
|
FlagsClientBrowser,
|
|
1601
1697
|
LABEL_MARKER_END,
|
|
1602
1698
|
LABEL_MARKER_RE,
|
package/dist/server/index.d.mts
CHANGED
|
@@ -10,15 +10,13 @@ interface Consequence {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* Non-exception problem, built by `violation(name)`. A plain branded object
|
|
13
|
-
* (not an Error subclass)
|
|
14
|
-
*
|
|
13
|
+
* (not an Error subclass). The name is the whole identity — there is no
|
|
14
|
+
* separate message; any variable/context data belongs in `.extras()` on the
|
|
15
|
+
* see chain, never on the violation itself.
|
|
15
16
|
*/
|
|
16
17
|
interface Violation {
|
|
17
18
|
readonly __seViolation: true;
|
|
18
19
|
readonly violationName: string;
|
|
19
|
-
readonly violationMessage?: string;
|
|
20
|
-
/** Attach free-form detail. Variable data goes HERE (or in extras), never in the name. */
|
|
21
|
-
message(msg: string): Violation;
|
|
22
20
|
}
|
|
23
21
|
/**
|
|
24
22
|
* Identity of a problem that see() already reported, carried on the wire as
|
|
@@ -87,12 +85,33 @@ interface SeeChain {
|
|
|
87
85
|
/** camelCase alias of {@link SeeChain.causes_the}. */
|
|
88
86
|
causesThe(subject: string): SeeOutcomeStep;
|
|
89
87
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Violations share the exception consequence grammar exactly — there is no
|
|
90
|
+
* separate `.message()`. Put any variable/context data in `.extras()`.
|
|
91
|
+
*/
|
|
92
|
+
type SeeViolationChain = SeeChain;
|
|
93
|
+
interface SeeControlFlowTail {
|
|
94
|
+
/**
|
|
95
|
+
* Optional debugging context for the expected exception. Kept on the mark for
|
|
96
|
+
* local debugging only (expected exceptions are never reported). Callable
|
|
97
|
+
* repeatedly — keys merge, later wins.
|
|
98
|
+
*/
|
|
99
|
+
extras(extras: SeeExtras): SeeControlFlowTail;
|
|
100
|
+
}
|
|
101
|
+
interface SeeControlFlowChain {
|
|
102
|
+
/** Document why the exception is expected. The reason should start with "because". */
|
|
103
|
+
because(reason: string): SeeControlFlowTail;
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
declare const version = "4.0.0";
|
|
107
|
+
/**
|
|
108
|
+
* @internal Exported ONLY so the cross-language eval-parity golden-vector test
|
|
109
|
+
* (src/__tests__/eval-vectors.test.ts) can assert the raw unsigned-32-bit hash
|
|
110
|
+
* against the canonical fixture. Not part of the public SDK surface — do not
|
|
111
|
+
* rely on it in product code; the bucketing contract lives behind getFlag /
|
|
112
|
+
* getExperiment. Name-prefixed with `_` to signal "test seam, not API".
|
|
113
|
+
*/
|
|
114
|
+
declare function _murmur3ForTests(key: string): number;
|
|
96
115
|
interface User {
|
|
97
116
|
user_id?: string;
|
|
98
117
|
anonymous_id?: string;
|
|
@@ -103,6 +122,30 @@ interface ExperimentResult<P> {
|
|
|
103
122
|
group: string;
|
|
104
123
|
params: P;
|
|
105
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
|
|
127
|
+
* Computed at the client boundary, never inside the canonical eval:
|
|
128
|
+
* - CLIENT_NOT_READY — no rules blob loaded yet (init()/initOnce() pending)
|
|
129
|
+
* - FLAG_NOT_FOUND — the gate name isn't present in the loaded blob
|
|
130
|
+
* - OFF — the gate exists but is disabled / killed
|
|
131
|
+
* - OVERRIDE — a local override (overrideFlag) decided the value
|
|
132
|
+
* - RULE_MATCH — the gate evaluated true (rules + rollout passed)
|
|
133
|
+
* - DEFAULT — the gate evaluated false (a rule or the rollout denied)
|
|
134
|
+
*/
|
|
135
|
+
declare const FLAG_REASONS: readonly ["CLIENT_NOT_READY", "FLAG_NOT_FOUND", "OFF", "OVERRIDE", "RULE_MATCH", "DEFAULT"];
|
|
136
|
+
type FlagReason = (typeof FLAG_REASONS)[number];
|
|
137
|
+
interface FlagDetail {
|
|
138
|
+
value: boolean;
|
|
139
|
+
reason: FlagReason;
|
|
140
|
+
}
|
|
141
|
+
/** Options object form of `getConfig` — keeps the legacy `decode` callback and
|
|
142
|
+
* adds a `defaultValue` returned when the config key is absent. */
|
|
143
|
+
interface GetConfigOptions<T = unknown> {
|
|
144
|
+
/** Decode the raw stored value into the typed shape callers want. */
|
|
145
|
+
decode?: (raw: unknown) => T;
|
|
146
|
+
/** Returned when the config key is absent (not overridden, not in the blob). */
|
|
147
|
+
defaultValue?: T;
|
|
148
|
+
}
|
|
106
149
|
interface GateRule {
|
|
107
150
|
attr: string;
|
|
108
151
|
op: "eq" | "neq" | "in" | "not_in" | "gt" | "gte" | "lt" | "lte" | "contains" | "regex";
|
|
@@ -115,10 +158,27 @@ interface Gate {
|
|
|
115
158
|
enabled: 0 | 1 | boolean;
|
|
116
159
|
killswitch?: 0 | 1 | boolean;
|
|
117
160
|
}
|
|
161
|
+
interface ExperimentGroup {
|
|
162
|
+
name: string;
|
|
163
|
+
weight: number;
|
|
164
|
+
params: Record<string, unknown>;
|
|
165
|
+
}
|
|
166
|
+
interface Experiment {
|
|
167
|
+
universe: string;
|
|
168
|
+
targetingGate?: string | null;
|
|
169
|
+
allocationPct: number;
|
|
170
|
+
salt: string;
|
|
171
|
+
groups: ExperimentGroup[];
|
|
172
|
+
status: "draft" | "running" | "stopped" | "archived";
|
|
173
|
+
}
|
|
174
|
+
interface Universe {
|
|
175
|
+
holdout_range: [number, number] | null;
|
|
176
|
+
}
|
|
118
177
|
interface Killswitch {
|
|
119
178
|
killed: 0 | 1 | boolean;
|
|
120
179
|
switches?: Record<string, 0 | 1 | boolean>;
|
|
121
180
|
}
|
|
181
|
+
/** Body of `GET /sdk/flags` — the snapshot's `flags` field. See {@link FlagsClient.fromSnapshot}. */
|
|
122
182
|
interface FlagsBlob {
|
|
123
183
|
version: string;
|
|
124
184
|
plan: string;
|
|
@@ -128,6 +188,12 @@ interface FlagsBlob {
|
|
|
128
188
|
}>;
|
|
129
189
|
killswitches: Record<string, Killswitch>;
|
|
130
190
|
}
|
|
191
|
+
/** Body of `GET /sdk/experiments` — the snapshot's `experiments` field. See {@link FlagsClient.fromSnapshot}. */
|
|
192
|
+
interface ExpsBlob {
|
|
193
|
+
version: string;
|
|
194
|
+
universes: Record<string, Universe>;
|
|
195
|
+
experiments: Record<string, Experiment>;
|
|
196
|
+
}
|
|
131
197
|
interface BootstrapPayload {
|
|
132
198
|
flags: Record<string, boolean>;
|
|
133
199
|
configs: Record<string, unknown>;
|
|
@@ -157,6 +223,13 @@ interface FlagsClientOptions {
|
|
|
157
223
|
disableTelemetry?: boolean;
|
|
158
224
|
/** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
|
|
159
225
|
telemetryUrl?: string;
|
|
226
|
+
/**
|
|
227
|
+
* Test mode — no network at all. init()/initOnce() are no-ops (never fetch),
|
|
228
|
+
* track() is a no-op, telemetry is forced off, and the client starts
|
|
229
|
+
* "initialized" with an empty blob. Prefer the {@link FlagsClient.forTesting}
|
|
230
|
+
* factory over passing this directly.
|
|
231
|
+
*/
|
|
232
|
+
testMode?: boolean;
|
|
160
233
|
}
|
|
161
234
|
declare class FlagsClient {
|
|
162
235
|
private readonly apiKey;
|
|
@@ -171,16 +244,89 @@ declare class FlagsClient {
|
|
|
171
244
|
private pollInterval;
|
|
172
245
|
private timer;
|
|
173
246
|
private initialized;
|
|
247
|
+
private readonly testMode;
|
|
248
|
+
private readonly flagOverrides;
|
|
249
|
+
private readonly configOverrides;
|
|
250
|
+
private readonly experimentOverrides;
|
|
251
|
+
private readonly changeListeners;
|
|
174
252
|
constructor(opts: FlagsClientOptions);
|
|
253
|
+
/**
|
|
254
|
+
* Build a no-network, immediately-usable client for tests (Statsig-style).
|
|
255
|
+
* init()/initOnce() are no-ops (never fetch), track() is a no-op, telemetry
|
|
256
|
+
* is disabled, and the client is already "initialized" — seed every entity
|
|
257
|
+
* with overrideFlag/overrideConfig/overrideExperiment. No SDK key required.
|
|
258
|
+
*
|
|
259
|
+
* ```ts
|
|
260
|
+
* const client = FlagsClient.forTesting();
|
|
261
|
+
* client.overrideFlag("new_checkout", true);
|
|
262
|
+
* client.getFlag("new_checkout", { user_id: "u1" }); // true
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
static forTesting(opts?: Partial<FlagsClientOptions>): FlagsClient;
|
|
266
|
+
/**
|
|
267
|
+
* Build a fully OFFLINE client from a pre-captured snapshot — no network ever.
|
|
268
|
+
* Reuses the test-mode plumbing (init()/initOnce()/track() are no-ops,
|
|
269
|
+
* telemetry off) but, unlike forTesting(), seeds the REAL flags + experiments
|
|
270
|
+
* blobs so evaluations run the canonical eval against the snapshot. Local
|
|
271
|
+
* overrides still apply on top.
|
|
272
|
+
*
|
|
273
|
+
* Snapshot shape mirrors the wire bodies:
|
|
274
|
+
* `{ flags: <GET /sdk/flags body>, experiments: <GET /sdk/experiments body> }`.
|
|
275
|
+
*/
|
|
276
|
+
static fromSnapshot(snapshot: {
|
|
277
|
+
flags: FlagsBlob;
|
|
278
|
+
experiments: ExpsBlob;
|
|
279
|
+
}): FlagsClient;
|
|
280
|
+
/**
|
|
281
|
+
* Build a fully OFFLINE client from a snapshot JSON file on disk (Node only —
|
|
282
|
+
* not available in the browser entrypoint). The file must contain
|
|
283
|
+
* `{ "flags": <GET /sdk/flags body>, "experiments": <GET /sdk/experiments body> }`.
|
|
284
|
+
* See {@link FlagsClient.fromSnapshot}.
|
|
285
|
+
*/
|
|
286
|
+
static fromFile(path: string): FlagsClient;
|
|
175
287
|
init(): Promise<void>;
|
|
176
288
|
initOnce(): Promise<void>;
|
|
289
|
+
/** Force `getFlag(name, …)` to return `value`, ignoring the fetched gate. */
|
|
290
|
+
overrideFlag(name: string, value: boolean): void;
|
|
291
|
+
/** Force `getConfig(name)` to return `value`, ignoring the fetched config. */
|
|
292
|
+
overrideConfig(name: string, value: unknown): void;
|
|
293
|
+
/**
|
|
294
|
+
* Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
|
|
295
|
+
* ignoring allocation, holdouts, and targeting.
|
|
296
|
+
*/
|
|
297
|
+
overrideExperiment(name: string, group: string, params: Record<string, unknown>): void;
|
|
298
|
+
/** Remove every programmatic override set via the override* methods. */
|
|
299
|
+
clearOverrides(): void;
|
|
177
300
|
destroy(): void;
|
|
301
|
+
/**
|
|
302
|
+
* Subscribe to data-change notifications. The listener fires after a
|
|
303
|
+
* background poll fetch returns NEW data (200, not 304) — i.e. after the
|
|
304
|
+
* cached blob is updated. Never fires in testMode/offline (no polling).
|
|
305
|
+
* Returns an unsubscribe function.
|
|
306
|
+
*/
|
|
307
|
+
onChange(listener: () => void): () => void;
|
|
308
|
+
private notifyChange;
|
|
178
309
|
private startPoll;
|
|
179
310
|
private fetchAll;
|
|
180
311
|
private fetchFlags;
|
|
181
312
|
private fetchExps;
|
|
182
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). The
|
|
315
|
+
* reason is computed entirely at this boundary — the canonical eval
|
|
316
|
+
* (evalGateInternal) is untouched. A local override short-circuits BEFORE
|
|
317
|
+
* telemetry, exactly like getFlag's override path; otherwise exactly one
|
|
318
|
+
* "gate" beacon is emitted.
|
|
319
|
+
*/
|
|
320
|
+
getFlagDetail(name: string, user: User): FlagDetail;
|
|
321
|
+
/**
|
|
322
|
+
* Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
|
|
323
|
+
* evaluated (client not initialized or flag not found) — never for a gate that
|
|
324
|
+
* legitimately evaluates to false. Plain `getFlag(name, user)` keeps returning
|
|
325
|
+
* false for a missing flag.
|
|
326
|
+
*/
|
|
327
|
+
getFlag(name: string, user: User, defaultValue?: boolean): boolean;
|
|
183
328
|
getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
|
|
329
|
+
getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
|
|
184
330
|
getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
|
|
185
331
|
track(userId: string, eventName: string, props?: Record<string, unknown>): void;
|
|
186
332
|
/**
|
|
@@ -327,8 +473,10 @@ declare const flags: {
|
|
|
327
473
|
initOnce(): Promise<void>;
|
|
328
474
|
/** Stop background timers. Safe to call repeatedly. */
|
|
329
475
|
destroy(): void;
|
|
330
|
-
get(name: string, user: User): boolean;
|
|
331
|
-
|
|
476
|
+
get(name: string, user: User, defaultValue?: boolean): boolean;
|
|
477
|
+
/** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
|
|
478
|
+
getDetail(name: string, user: User): FlagDetail;
|
|
479
|
+
getConfig<T = unknown>(name: string, decodeOrOpts?: ((raw: unknown) => T) | GetConfigOptions<T>): T | undefined;
|
|
332
480
|
getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
|
|
333
481
|
/**
|
|
334
482
|
* Read a killswitch. Without `switchKey`, returns true when the whole
|
|
@@ -367,20 +515,26 @@ interface SeeApi {
|
|
|
367
515
|
/**
|
|
368
516
|
* Report a non-exception problem. Prefer passing a caught Error to `see()`
|
|
369
517
|
* when one exists. The name is a stable identifier (it participates in the
|
|
370
|
-
* issue fingerprint) — variable data goes in `.
|
|
518
|
+
* issue fingerprint) — variable data goes in `.extras()`, never the name.
|
|
371
519
|
*
|
|
372
520
|
* ```ts
|
|
373
521
|
* if (results.length > LIMIT) {
|
|
374
|
-
* see.Violation("large query")
|
|
522
|
+
* see.Violation("large query")
|
|
523
|
+
* .causes_the("search results").to("be trimmed").extras({ rows: results.length });
|
|
375
524
|
* }
|
|
376
525
|
* ```
|
|
377
526
|
*/
|
|
378
527
|
Violation(name: string): SeeViolationChain;
|
|
379
528
|
/**
|
|
380
529
|
* Mark an exception as expected control flow — documents the expectation and
|
|
381
|
-
* reports nothing.
|
|
530
|
+
* reports nothing. Say why with `.because()` (reason should start with
|
|
531
|
+
* "because"); attach optional debug context with `.extras()`.
|
|
532
|
+
*
|
|
533
|
+
* ```ts
|
|
534
|
+
* see.ControlFlowException(e).because("because the metric may not exist yet");
|
|
535
|
+
* ```
|
|
382
536
|
*/
|
|
383
|
-
ControlFlowException(err: unknown
|
|
537
|
+
ControlFlowException(err: unknown): SeeControlFlowChain;
|
|
384
538
|
}
|
|
385
539
|
/**
|
|
386
540
|
* Structured error reporter — the whole grammar hangs off this one import.
|
|
@@ -389,4 +543,4 @@ interface SeeApi {
|
|
|
389
543
|
*/
|
|
390
544
|
declare const see: SeeApi;
|
|
391
545
|
|
|
392
|
-
export { ANON_ID_COOKIE, type BootstrapHtmlOptions, type BootstrapPayload, type Consequence, type ExperimentResult, type FetchLabelsOptions, FlagsClient, type FlagsClientEnv, type FlagsClientOptions, type I18nForRequest, type LabelFile, type SeeApi, type SeeChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyServerConfig, type ShipeasyServerHandle, type User, type Violation, _resetShipeasyServerForTests, configureShipeasyServer, fetchLabelsForSSR, flags, getBootstrapHtml, getShipeasyServerClient, i18n, isExpected, see, seeContext, shipeasy, version };
|
|
546
|
+
export { ANON_ID_COOKIE, type BootstrapHtmlOptions, type BootstrapPayload, type Consequence, type ExperimentResult, type ExpsBlob, FLAG_REASONS, type FetchLabelsOptions, type FlagDetail, type FlagReason, type FlagsBlob, FlagsClient, type FlagsClientEnv, type FlagsClientOptions, type GetConfigOptions, type I18nForRequest, type LabelFile, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyServerConfig, type ShipeasyServerHandle, type User, type Violation, _murmur3ForTests, _resetShipeasyServerForTests, configureShipeasyServer, fetchLabelsForSSR, flags, getBootstrapHtml, getShipeasyServerClient, i18n, isExpected, see, seeContext, shipeasy, version };
|