@shipeasy/sdk 6.3.1 → 7.0.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 +1 -4
- package/dist/client/index.d.mts +128 -22
- package/dist/client/index.d.ts +128 -22
- package/dist/client/index.js +238 -133
- package/dist/client/index.mjs +238 -133
- package/dist/openfeature-server/index.d.mts +117 -8
- package/dist/openfeature-server/index.d.ts +117 -8
- package/dist/openfeature-server/index.js +65 -33
- package/dist/openfeature-server/index.mjs +65 -33
- package/dist/openfeature-web/index.d.mts +89 -27
- package/dist/openfeature-web/index.d.ts +89 -27
- package/dist/server/index.d.mts +145 -21
- package/dist/server/index.d.ts +145 -21
- package/dist/server/index.js +305 -158
- package/dist/server/index.mjs +305 -158
- package/docs/skill/SKILL.md +14 -10
- package/package.json +3 -1
package/dist/server/index.mjs
CHANGED
|
@@ -67,53 +67,6 @@ function send(url) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// src/logger.ts
|
|
71
|
-
var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
|
|
72
|
-
var RANK = {
|
|
73
|
-
silent: 0,
|
|
74
|
-
error: 1,
|
|
75
|
-
warn: 2,
|
|
76
|
-
info: 3,
|
|
77
|
-
debug: 4
|
|
78
|
-
};
|
|
79
|
-
var currentLevel = "warn";
|
|
80
|
-
function setLogLevel(level) {
|
|
81
|
-
if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
|
|
82
|
-
currentLevel = level;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
function write(method, args) {
|
|
86
|
-
try {
|
|
87
|
-
const c = globalThis.console;
|
|
88
|
-
if (!c) return;
|
|
89
|
-
const fn = c[method] ?? c.log;
|
|
90
|
-
if (typeof fn === "function") fn.call(c, ...args);
|
|
91
|
-
} catch {
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
var logger = {
|
|
95
|
-
error(...args) {
|
|
96
|
-
if (RANK[currentLevel] >= RANK.error) write("error", args);
|
|
97
|
-
},
|
|
98
|
-
warn(...args) {
|
|
99
|
-
if (RANK[currentLevel] >= RANK.warn) write("warn", args);
|
|
100
|
-
},
|
|
101
|
-
info(...args) {
|
|
102
|
-
if (RANK[currentLevel] >= RANK.info) write("info", args);
|
|
103
|
-
},
|
|
104
|
-
debug(...args) {
|
|
105
|
-
if (RANK[currentLevel] >= RANK.debug) write("debug", args);
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
function safeRun(label, fallback, fn) {
|
|
109
|
-
try {
|
|
110
|
-
return fn();
|
|
111
|
-
} catch (err) {
|
|
112
|
-
logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
|
|
113
|
-
return fallback;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
70
|
// src/see/core.ts
|
|
118
71
|
var SEE_MAX_MESSAGE = 500;
|
|
119
72
|
var SEE_MAX_STACK = 8e3;
|
|
@@ -231,7 +184,7 @@ function captureCallsiteStack() {
|
|
|
231
184
|
const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
|
|
232
185
|
return kept.length ? kept.join("\n") : void 0;
|
|
233
186
|
}
|
|
234
|
-
function buildSeeEvent(problem, consequence, extras,
|
|
187
|
+
function buildSeeEvent(problem, consequence, extras, ctx2, kindOverride, correlationId) {
|
|
235
188
|
let errorType;
|
|
236
189
|
let message;
|
|
237
190
|
let stack;
|
|
@@ -259,8 +212,8 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
|
|
|
259
212
|
message: truncate(message, SEE_MAX_MESSAGE),
|
|
260
213
|
subject: consequence.subject,
|
|
261
214
|
outcome: consequence.outcome,
|
|
262
|
-
side:
|
|
263
|
-
sdk_version:
|
|
215
|
+
side: ctx2.side,
|
|
216
|
+
sdk_version: ctx2.sdkVersion,
|
|
264
217
|
ts: Date.now()
|
|
265
218
|
};
|
|
266
219
|
if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
|
|
@@ -269,10 +222,10 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
|
|
|
269
222
|
if (causedBy) ev.caused_by = causedBy;
|
|
270
223
|
const cleanExtras = sanitizeExtras(extras);
|
|
271
224
|
if (cleanExtras) ev.extras = cleanExtras;
|
|
272
|
-
if (
|
|
273
|
-
if (
|
|
274
|
-
if (
|
|
275
|
-
if (
|
|
225
|
+
if (ctx2.url) ev.url = truncate(ctx2.url, SEE_MAX_SUBJECT);
|
|
226
|
+
if (ctx2.userId) ev.user_id = ctx2.userId;
|
|
227
|
+
if (ctx2.anonId) ev.anonymous_id = ctx2.anonId;
|
|
228
|
+
if (ctx2.env) ev.env = ctx2.env;
|
|
276
229
|
markReported(problem, ev);
|
|
277
230
|
return ev;
|
|
278
231
|
}
|
|
@@ -365,6 +318,98 @@ var SeeLimiter = class {
|
|
|
365
318
|
}
|
|
366
319
|
};
|
|
367
320
|
|
|
321
|
+
// src/internal-report.ts
|
|
322
|
+
var INGEST_URL = "https://api.shipeasy.ai/collect";
|
|
323
|
+
var PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY";
|
|
324
|
+
var INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5";
|
|
325
|
+
function keyConfigured() {
|
|
326
|
+
return !!INGEST_KEY && INGEST_KEY !== PLACEHOLDER_KEY;
|
|
327
|
+
}
|
|
328
|
+
var OUTCOME = "returned a safe default";
|
|
329
|
+
var SDK_ID = "ts";
|
|
330
|
+
var ctx = null;
|
|
331
|
+
var limiter = new SeeLimiter();
|
|
332
|
+
function setInternalReportContext(c) {
|
|
333
|
+
ctx = { side: c.side, sdkVersion: c.sdkVersion, enabled: c.enabled !== false };
|
|
334
|
+
}
|
|
335
|
+
function reportInternalError(label, err) {
|
|
336
|
+
try {
|
|
337
|
+
if (!ctx || !ctx.enabled || !keyConfigured()) return;
|
|
338
|
+
const ev = buildSeeEvent(
|
|
339
|
+
err,
|
|
340
|
+
causesThe(label).to(OUTCOME),
|
|
341
|
+
{ sdk: SDK_ID },
|
|
342
|
+
{ side: ctx.side, sdkVersion: ctx.sdkVersion },
|
|
343
|
+
"caught"
|
|
344
|
+
);
|
|
345
|
+
if (!limiter.shouldSend(ev)) return;
|
|
346
|
+
send2(INGEST_URL, INGEST_KEY, JSON.stringify({ events: [ev] }));
|
|
347
|
+
} catch {
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function send2(url, key, body) {
|
|
351
|
+
try {
|
|
352
|
+
const f = globalThis.fetch;
|
|
353
|
+
if (typeof f !== "function") return;
|
|
354
|
+
void f(url, {
|
|
355
|
+
method: "POST",
|
|
356
|
+
headers: { "X-SDK-Key": key, "Content-Type": "text/plain" },
|
|
357
|
+
body,
|
|
358
|
+
keepalive: true
|
|
359
|
+
}).catch(() => {
|
|
360
|
+
});
|
|
361
|
+
} catch {
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/logger.ts
|
|
366
|
+
var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
|
|
367
|
+
var RANK = {
|
|
368
|
+
silent: 0,
|
|
369
|
+
error: 1,
|
|
370
|
+
warn: 2,
|
|
371
|
+
info: 3,
|
|
372
|
+
debug: 4
|
|
373
|
+
};
|
|
374
|
+
var currentLevel = "warn";
|
|
375
|
+
function setLogLevel(level) {
|
|
376
|
+
if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
|
|
377
|
+
currentLevel = level;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function write(method, args) {
|
|
381
|
+
try {
|
|
382
|
+
const c = globalThis.console;
|
|
383
|
+
if (!c) return;
|
|
384
|
+
const fn = c[method] ?? c.log;
|
|
385
|
+
if (typeof fn === "function") fn.call(c, ...args);
|
|
386
|
+
} catch {
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
var logger = {
|
|
390
|
+
error(...args) {
|
|
391
|
+
if (RANK[currentLevel] >= RANK.error) write("error", args);
|
|
392
|
+
},
|
|
393
|
+
warn(...args) {
|
|
394
|
+
if (RANK[currentLevel] >= RANK.warn) write("warn", args);
|
|
395
|
+
},
|
|
396
|
+
info(...args) {
|
|
397
|
+
if (RANK[currentLevel] >= RANK.info) write("info", args);
|
|
398
|
+
},
|
|
399
|
+
debug(...args) {
|
|
400
|
+
if (RANK[currentLevel] >= RANK.debug) write("debug", args);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
function safeRun(label, fallback, fn) {
|
|
404
|
+
try {
|
|
405
|
+
return fn();
|
|
406
|
+
} catch (err) {
|
|
407
|
+
logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
|
|
408
|
+
reportInternalError(label, err);
|
|
409
|
+
return fallback;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
368
413
|
// src/server/index.ts
|
|
369
414
|
var version = "4.0.0";
|
|
370
415
|
var C1 = 3432918353;
|
|
@@ -501,6 +546,78 @@ function evalGateInternal(gate, user) {
|
|
|
501
546
|
if (!uid) return gate.rolloutPct >= 1e4;
|
|
502
547
|
return murmur3(`${gate.salt}:${uid}`) % 1e4 < gate.rolloutPct;
|
|
503
548
|
}
|
|
549
|
+
function paramDefaultsFromSchema(schema) {
|
|
550
|
+
if (!schema || schema.length === 0) return null;
|
|
551
|
+
const out = {};
|
|
552
|
+
for (const p of schema) out[p.name] = p.default;
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
function mergeParams(paramDefaults, groupParams) {
|
|
556
|
+
return paramDefaults ? { ...paramDefaults, ...groupParams } : { ...groupParams };
|
|
557
|
+
}
|
|
558
|
+
function classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky) {
|
|
559
|
+
const asGroup = (g) => ({
|
|
560
|
+
state: "group",
|
|
561
|
+
group: g.name,
|
|
562
|
+
params: mergeParams(paramDefaults, g.params)
|
|
563
|
+
});
|
|
564
|
+
if (exp.targetingGate && !evalGate(exp.targetingGate)) return { state: "out" };
|
|
565
|
+
const uid = pickIdentifier(user, exp.bucketBy);
|
|
566
|
+
if (!uid) return { state: "out" };
|
|
567
|
+
const universeSeg = murmur3(`${exp.universe}:${uid}`) % 1e4;
|
|
568
|
+
if (holdoutRange) {
|
|
569
|
+
const [lo, hi] = holdoutRange;
|
|
570
|
+
if (universeSeg >= lo && universeSeg <= hi) return { state: "holdout" };
|
|
571
|
+
}
|
|
572
|
+
if (exp.holdoutGate && evalGate(exp.holdoutGate)) return { state: "holdout" };
|
|
573
|
+
const salt8 = exp.salt.slice(0, 8);
|
|
574
|
+
if (sticky) {
|
|
575
|
+
const entry = sticky.get();
|
|
576
|
+
if (entry && entry.s === salt8) {
|
|
577
|
+
const g = exp.groups.find((x) => x.name === entry.g);
|
|
578
|
+
if (g) return asGroup(g);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const pooled = (exp.hashVersion ?? 1) >= 2 && exp.poolOffsetBp != null && exp.poolSizeBp != null && exp.poolSizeBp > 0;
|
|
582
|
+
if (pooled) {
|
|
583
|
+
const lo = exp.poolOffsetBp;
|
|
584
|
+
const hi = lo + exp.poolSizeBp;
|
|
585
|
+
if (universeSeg < lo || universeSeg >= hi) return { state: "out" };
|
|
586
|
+
} else {
|
|
587
|
+
if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return { state: "out" };
|
|
588
|
+
}
|
|
589
|
+
const reserved = Math.max(0, Math.min(1e4, exp.reservedHeadroomBp ?? 0));
|
|
590
|
+
const usable = 1e4 - reserved;
|
|
591
|
+
const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
|
|
592
|
+
if (groupHash >= usable) return { state: "out" };
|
|
593
|
+
let cumulative = 0;
|
|
594
|
+
for (let i = 0; i < exp.groups.length; i++) {
|
|
595
|
+
const g = exp.groups[i];
|
|
596
|
+
cumulative += g.weight;
|
|
597
|
+
if (groupHash < cumulative || i === exp.groups.length - 1) {
|
|
598
|
+
sticky?.set({ g: g.name, s: salt8 });
|
|
599
|
+
return asGroup(g);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return { state: "out" };
|
|
603
|
+
}
|
|
604
|
+
var AssignmentImpl = class {
|
|
605
|
+
constructor(name, group, params) {
|
|
606
|
+
this.name = name;
|
|
607
|
+
this.group = group;
|
|
608
|
+
this.params = params;
|
|
609
|
+
}
|
|
610
|
+
name;
|
|
611
|
+
group;
|
|
612
|
+
params;
|
|
613
|
+
get enrolled() {
|
|
614
|
+
return this.group !== null;
|
|
615
|
+
}
|
|
616
|
+
get(field, fallback) {
|
|
617
|
+
const v = this.params[field];
|
|
618
|
+
return v === void 0 ? fallback : v;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
504
621
|
var TRUE_RX = /^(true|on|1|yes)$/i;
|
|
505
622
|
var FALSE_RX = /^(false|off|0|no)$/i;
|
|
506
623
|
function parseOverrideBool(raw) {
|
|
@@ -571,6 +688,9 @@ var Engine = class _Engine {
|
|
|
571
688
|
flagOverrides = /* @__PURE__ */ new Map();
|
|
572
689
|
configOverrides = /* @__PURE__ */ new Map();
|
|
573
690
|
experimentOverrides = /* @__PURE__ */ new Map();
|
|
691
|
+
// Bounded per-process exposure dedup (`uid:exp:group`) so auto-exposure from
|
|
692
|
+
// repeated assign() calls doesn't spam /collect. Cleared past a soft cap.
|
|
693
|
+
exposureSeen = /* @__PURE__ */ new Set();
|
|
574
694
|
// Change listeners fired after a background poll returns NEW data (200, not
|
|
575
695
|
// 304). Never fired in testMode/offline (no polling happens there).
|
|
576
696
|
changeListeners = /* @__PURE__ */ new Set();
|
|
@@ -582,6 +702,11 @@ var Engine = class _Engine {
|
|
|
582
702
|
this.privateAttributes = opts.privateAttributes ?? [];
|
|
583
703
|
this.stickyStore = opts.stickyStore;
|
|
584
704
|
this.testMode = opts.testMode === true;
|
|
705
|
+
setInternalReportContext({
|
|
706
|
+
side: "server",
|
|
707
|
+
sdkVersion: version,
|
|
708
|
+
enabled: !this.testMode && opts.disableInternalErrorReporting !== true
|
|
709
|
+
});
|
|
585
710
|
this.telemetry = new Telemetry({
|
|
586
711
|
endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
|
|
587
712
|
sdkKey: this.apiKey,
|
|
@@ -792,68 +917,77 @@ var Engine = class _Engine {
|
|
|
792
917
|
return "defaultValue" in opts ? opts.defaultValue : void 0;
|
|
793
918
|
}
|
|
794
919
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
920
|
+
/**
|
|
921
|
+
* Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
|
|
922
|
+
* for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
|
|
923
|
+
*/
|
|
924
|
+
bindSticky(name, uid) {
|
|
925
|
+
const store = this.stickyStore;
|
|
926
|
+
if (!store) return void 0;
|
|
927
|
+
return {
|
|
928
|
+
get: () => store.get(uid)?.[name],
|
|
929
|
+
set: (entry) => store.set(uid, name, entry)
|
|
801
930
|
};
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Evaluate one experiment by name for `user` — override → full classify
|
|
934
|
+
* pipeline (targeting → universe holdout → holdout gate → sticky → allocation
|
|
935
|
+
* → group), merging the universe defaults under the assigned variant (§B2).
|
|
936
|
+
* Internal: the public surface is `universe(name).assign(user)`. Reused by the
|
|
937
|
+
* SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
|
|
938
|
+
*/
|
|
939
|
+
evalExperiment(name, exp, user) {
|
|
940
|
+
const paramDefaults = paramDefaultsFromSchema(
|
|
941
|
+
this.expsBlob?.universes[exp.universe]?.param_schema
|
|
942
|
+
);
|
|
802
943
|
const ov = this.experimentOverrides.get(name);
|
|
803
|
-
if (ov) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
if (!this.flagsBlob || !this.expsBlob) return notIn;
|
|
813
|
-
const exp = this.expsBlob.experiments[name];
|
|
814
|
-
if (!exp || exp.status !== "running") return notIn;
|
|
815
|
-
if (exp.targetingGate) {
|
|
816
|
-
const gate = this.flagsBlob.gates[exp.targetingGate];
|
|
817
|
-
if (!gate || !evalGateInternal(gate, user)) return notIn;
|
|
818
|
-
}
|
|
819
|
-
const uid = pickIdentifier(user, exp.bucketBy);
|
|
820
|
-
if (!uid) return notIn;
|
|
821
|
-
const universe = this.expsBlob.universes[exp.universe];
|
|
822
|
-
const holdoutRange = universe?.holdout_range ?? null;
|
|
823
|
-
if (holdoutRange) {
|
|
824
|
-
const seg = murmur3(`${exp.universe}:${uid}`) % 1e4;
|
|
825
|
-
const [lo, hi] = holdoutRange;
|
|
826
|
-
if (seg >= lo && seg <= hi) return notIn;
|
|
827
|
-
}
|
|
828
|
-
const asResult = (g) => {
|
|
829
|
-
if (!decode) return { inExperiment: true, group: g.name, params: g.params };
|
|
830
|
-
try {
|
|
831
|
-
return { inExperiment: true, group: g.name, params: decode(g.params) };
|
|
832
|
-
} catch (err) {
|
|
833
|
-
logger.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
|
|
834
|
-
return notIn;
|
|
835
|
-
}
|
|
944
|
+
if (ov) return { state: "group", group: ov.group, params: mergeParams(paramDefaults, ov.params) };
|
|
945
|
+
if (!this.flagsBlob || !this.expsBlob) return { state: "out" };
|
|
946
|
+
if (exp.status !== "running") return { state: "out" };
|
|
947
|
+
const holdoutRange = this.expsBlob.universes[exp.universe]?.holdout_range ?? null;
|
|
948
|
+
const evalGate = (gname) => {
|
|
949
|
+
const gate = this.flagsBlob?.gates[gname];
|
|
950
|
+
return gate ? evalGateInternal(gate, user) : false;
|
|
836
951
|
};
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
952
|
+
const uid = pickIdentifier(user, exp.bucketBy);
|
|
953
|
+
const sticky = uid ? this.bindSticky(name, uid) : void 0;
|
|
954
|
+
return classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky);
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
|
|
958
|
+
* so a unit lands in **at most one** experiment; the returned {@link Assignment}
|
|
959
|
+
* exposes the variant + resolved params and auto-logs a single exposure when
|
|
960
|
+
* enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
|
|
961
|
+
* Never throws. This is the sole experiment read path (there is no
|
|
962
|
+
* `getExperiment` — a caller asks a universe, not an experiment).
|
|
963
|
+
*/
|
|
964
|
+
assignUniverse(universeName, user) {
|
|
965
|
+
this.telemetry.emit("experiment", universeName);
|
|
966
|
+
const paramDefaults = paramDefaultsFromSchema(
|
|
967
|
+
this.expsBlob?.universes[universeName]?.param_schema
|
|
968
|
+
);
|
|
969
|
+
const notEnrolled = () => new AssignmentImpl(null, null, paramDefaults ?? {});
|
|
970
|
+
if (!this.expsBlob) return notEnrolled();
|
|
971
|
+
const candidates = Object.entries(this.expsBlob.experiments).filter(([, e]) => e.universe === universeName && e.status === "running").sort(
|
|
972
|
+
(a, b) => (a[1].poolOffsetBp ?? 0) - (b[1].poolOffsetBp ?? 0) || a[0].localeCompare(b[0])
|
|
973
|
+
);
|
|
974
|
+
for (const [name, exp] of candidates) {
|
|
975
|
+
const c = this.evalExperiment(name, exp, user);
|
|
976
|
+
if (c.state === "group") {
|
|
977
|
+
this.postExposure(user, name, c.group);
|
|
978
|
+
return new AssignmentImpl(name, c.group, c.params ?? {});
|
|
854
979
|
}
|
|
855
980
|
}
|
|
856
|
-
return
|
|
981
|
+
return notEnrolled();
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* The universe-first experiment read entry point:
|
|
985
|
+
* `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
|
|
986
|
+
* to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
|
|
987
|
+
* into and auto-logs a single exposure. See {@link assignUniverse}.
|
|
988
|
+
*/
|
|
989
|
+
universe(name) {
|
|
990
|
+
return { assign: (user) => this.assignUniverse(name, user) };
|
|
857
991
|
}
|
|
858
992
|
/** Drop caller-marked private attributes from an outbound props bag. */
|
|
859
993
|
stripPrivate(props) {
|
|
@@ -885,26 +1019,26 @@ var Engine = class _Engine {
|
|
|
885
1019
|
}).catch((err) => logger.warn("[shipeasy] track failed:", String(err)));
|
|
886
1020
|
}
|
|
887
1021
|
/**
|
|
888
|
-
*
|
|
889
|
-
* (
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
* as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
|
|
893
|
-
* `/collect`. No-op in test mode or when the user isn't enrolled.
|
|
1022
|
+
* POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
|
|
1023
|
+
* per process (bounded set) so repeated `assign()` calls in one server don't
|
|
1024
|
+
* spam `/collect`. Fire-and-forget; no-op in test mode. This is how
|
|
1025
|
+
* `assignUniverse` auto-logs — the browser's auto-exposure parity for SSR.
|
|
894
1026
|
*/
|
|
895
|
-
|
|
1027
|
+
postExposure(user, experiment, group) {
|
|
896
1028
|
if (this.testMode) return;
|
|
897
|
-
const
|
|
898
|
-
const
|
|
899
|
-
if (
|
|
1029
|
+
const uid = user.user_id ?? user.anonymous_id;
|
|
1030
|
+
const dedupKey = `${uid ?? ""}:${experiment}:${group}`;
|
|
1031
|
+
if (this.exposureSeen.has(dedupKey)) return;
|
|
1032
|
+
if (this.exposureSeen.size > 5e3) this.exposureSeen.clear();
|
|
1033
|
+
this.exposureSeen.add(dedupKey);
|
|
900
1034
|
const body = JSON.stringify({
|
|
901
1035
|
events: [
|
|
902
1036
|
{
|
|
903
1037
|
type: "exposure",
|
|
904
|
-
experiment
|
|
905
|
-
group
|
|
906
|
-
...
|
|
907
|
-
...
|
|
1038
|
+
experiment,
|
|
1039
|
+
group,
|
|
1040
|
+
...user.user_id !== void 0 ? { user_id: user.user_id } : {},
|
|
1041
|
+
...user.anonymous_id !== void 0 ? { anonymous_id: user.anonymous_id } : {},
|
|
908
1042
|
ts: Date.now()
|
|
909
1043
|
}
|
|
910
1044
|
]
|
|
@@ -913,7 +1047,7 @@ var Engine = class _Engine {
|
|
|
913
1047
|
method: "POST",
|
|
914
1048
|
headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
|
|
915
1049
|
body
|
|
916
|
-
}).catch((err) => logger.warn("[shipeasy]
|
|
1050
|
+
}).catch((err) => logger.warn("[shipeasy] exposure send failed:", String(err)));
|
|
917
1051
|
}
|
|
918
1052
|
/**
|
|
919
1053
|
* Report a structured error into the errors primitive. Fire-and-forget —
|
|
@@ -959,8 +1093,17 @@ var Engine = class _Engine {
|
|
|
959
1093
|
this.telemetry.emit("config", name);
|
|
960
1094
|
configs[name] = entry.value;
|
|
961
1095
|
}
|
|
962
|
-
|
|
963
|
-
|
|
1096
|
+
const universes = {};
|
|
1097
|
+
for (const [name, exp] of Object.entries(this.expsBlob?.experiments ?? {})) {
|
|
1098
|
+
this.telemetry.emit("experiment", exp.universe);
|
|
1099
|
+
const uniName = exp.universe;
|
|
1100
|
+
if (!(uniName in universes)) {
|
|
1101
|
+
universes[uniName] = {
|
|
1102
|
+
defaults: paramDefaultsFromSchema(this.expsBlob?.universes[uniName]?.param_schema) ?? {}
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
const c = this.evalExperiment(name, exp, user);
|
|
1106
|
+
experiments[name] = c.state === "group" ? { inExperiment: true, group: c.group, params: c.params ?? {}, universe: uniName } : { inExperiment: false, group: "control", params: {}, universe: uniName };
|
|
964
1107
|
}
|
|
965
1108
|
for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
|
|
966
1109
|
this.telemetry.emit("ks", name);
|
|
@@ -977,10 +1120,11 @@ var Engine = class _Engine {
|
|
|
977
1120
|
Object.assign(flags2, ov.gates);
|
|
978
1121
|
Object.assign(configs, ov.configs);
|
|
979
1122
|
for (const [name, group] of Object.entries(ov.experiments)) {
|
|
980
|
-
experiments[name]
|
|
1123
|
+
const uni = this.expsBlob?.experiments[name]?.universe;
|
|
1124
|
+
experiments[name] = { inExperiment: true, group, params: {}, ...uni ? { universe: uni } : {} };
|
|
981
1125
|
}
|
|
982
1126
|
}
|
|
983
|
-
return { flags: flags2, configs, experiments, killswitches };
|
|
1127
|
+
return { flags: flags2, configs, experiments, killswitches, universes };
|
|
984
1128
|
}
|
|
985
1129
|
getKillswitch(name, switchKey) {
|
|
986
1130
|
this.telemetry.emit("ks", name);
|
|
@@ -1128,6 +1272,7 @@ async function shipeasy(opts) {
|
|
|
1128
1272
|
flags.configure({
|
|
1129
1273
|
apiKey: serverKey,
|
|
1130
1274
|
disableTelemetry: opts.disableTelemetry,
|
|
1275
|
+
disableInternalErrorReporting: opts.disableInternalErrorReporting,
|
|
1131
1276
|
privateAttributes: opts.privateAttributes,
|
|
1132
1277
|
logLevel: opts.logLevel
|
|
1133
1278
|
});
|
|
@@ -1272,13 +1417,22 @@ var flags = {
|
|
|
1272
1417
|
() => _server?.getConfig(name, decodeOrOpts)
|
|
1273
1418
|
);
|
|
1274
1419
|
},
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1420
|
+
/**
|
|
1421
|
+
* Assign `user` within a universe: `flags.universe("checkout").assign(user)`.
|
|
1422
|
+
* A universe is a mutual-exclusion pool, so the unit lands in ≤1 experiment;
|
|
1423
|
+
* the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
|
|
1424
|
+
* and auto-logs one exposure when enrolled. Before configure() (or on any
|
|
1425
|
+
* error) it returns a safe not-enrolled handle. This replaces the removed
|
|
1426
|
+
* `getExperiment` — read experiments by universe, never by name.
|
|
1427
|
+
*/
|
|
1428
|
+
universe(name) {
|
|
1429
|
+
return {
|
|
1430
|
+
assign: (user) => safeRun(
|
|
1431
|
+
"flags.universe.assign",
|
|
1432
|
+
new AssignmentImpl(null, null, {}),
|
|
1433
|
+
() => _server?.assignUniverse(name, user) ?? new AssignmentImpl(null, null, {})
|
|
1434
|
+
)
|
|
1435
|
+
};
|
|
1282
1436
|
},
|
|
1283
1437
|
/**
|
|
1284
1438
|
* Read a killswitch. Without `switchKey`, returns true when the whole
|
|
@@ -1291,11 +1445,6 @@ var flags = {
|
|
|
1291
1445
|
track(userId, eventName, props) {
|
|
1292
1446
|
safeRun("flags.track", void 0, () => _server?.track(userId, eventName, props));
|
|
1293
1447
|
},
|
|
1294
|
-
/** Emit an exposure for an enrolled experiment at the decision point. See
|
|
1295
|
-
* {@link Engine.logExposure}. No-op before configure(). */
|
|
1296
|
-
logExposure(user, name) {
|
|
1297
|
-
safeRun("flags.logExposure", void 0, () => _server?.logExposure(user, name));
|
|
1298
|
-
},
|
|
1299
1448
|
/**
|
|
1300
1449
|
* Evaluate all flags / configs / experiments for a user against the locally
|
|
1301
1450
|
* cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
|
|
@@ -1412,13 +1561,20 @@ var Client = class {
|
|
|
1412
1561
|
() => this.engine.getConfig(name, decodeOrOpts)
|
|
1413
1562
|
);
|
|
1414
1563
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1564
|
+
/**
|
|
1565
|
+
* Assign the bound user within a universe: `client.universe("checkout").assign()`.
|
|
1566
|
+
* The user is already bound at construction, so `assign()` takes no arg. Returns
|
|
1567
|
+
* an {@link Assignment} (`.group` / `.get(field, fallback)`) and auto-logs one
|
|
1568
|
+
* exposure when enrolled. Replaces the removed `getExperiment`.
|
|
1569
|
+
*/
|
|
1570
|
+
universe(name) {
|
|
1571
|
+
return {
|
|
1572
|
+
assign: () => safeRun(
|
|
1573
|
+
"Client.universe.assign",
|
|
1574
|
+
new AssignmentImpl(null, null, {}),
|
|
1575
|
+
() => this.engine.assignUniverse(name, this.attributes)
|
|
1576
|
+
)
|
|
1577
|
+
};
|
|
1422
1578
|
}
|
|
1423
1579
|
/** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
|
|
1424
1580
|
getKillswitch(name, switchKey) {
|
|
@@ -1438,15 +1594,6 @@ var Client = class {
|
|
|
1438
1594
|
this.engine.track(String(id), eventName, props);
|
|
1439
1595
|
});
|
|
1440
1596
|
}
|
|
1441
|
-
/**
|
|
1442
|
-
* Emit an exposure event for `name` at this server-side decision point for the
|
|
1443
|
-
* bound user. Delegates to {@link Engine.logExposure} with the resolved
|
|
1444
|
-
* attribute bag (re-evaluates enrolment; no-op when the user isn't enrolled or
|
|
1445
|
-
* in test mode).
|
|
1446
|
-
*/
|
|
1447
|
-
logExposure(name) {
|
|
1448
|
-
safeRun("Client.logExposure", void 0, () => this.engine.logExposure(this.attributes, name));
|
|
1449
|
-
}
|
|
1450
1597
|
};
|
|
1451
1598
|
function dispatchSee(problem, consequence, extras, kind) {
|
|
1452
1599
|
if (!_server) {
|
package/docs/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: shipeasy-typescript
|
|
3
|
-
description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from TypeScript / JavaScript. Covers configure() + Client(user), getFlag/getConfig/getKillswitch/
|
|
3
|
+
description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from TypeScript / JavaScript. Covers configure() + Client(user), getFlag/getConfig/getKillswitch/universe().assign(), track, testing, OpenFeature, and the see() error reporter — server (@shipeasy/sdk/server) and browser (@shipeasy/sdk/client).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Shipeasy TypeScript SDK
|
|
@@ -52,17 +52,21 @@ snippets <https://shipeasy-ai.github.io/sdk-ts/snippets/release/flags.md> ·
|
|
|
52
52
|
|
|
53
53
|
```ts
|
|
54
54
|
const flags = new Client(currentUser); // construct once per callsite
|
|
55
|
-
const { inExperiment, group, params } = flags.getExperiment("hero_cta", {
|
|
56
|
-
primary_label: "Sign up", // default params (control / not-enrolled)
|
|
57
|
-
});
|
|
58
|
-
render(params.primary_label);
|
|
59
55
|
|
|
60
|
-
|
|
56
|
+
// Read experiments by UNIVERSE (a mutual-exclusion pool — the unit lands in ≤1
|
|
57
|
+
// experiment). assign() auto-logs one deduped exposure when enrolled.
|
|
58
|
+
const exp = flags.universe("hero_cta").assign();
|
|
59
|
+
render(exp.get("primary_label", "Sign up")); // variant ?? universe default ?? fallback
|
|
60
|
+
|
|
61
|
+
if (exp.enrolled) {
|
|
62
|
+
// exp.group is the variant, exp.name is the experiment
|
|
63
|
+
}
|
|
61
64
|
flags.track("purchase", { value: 42 }); // record a conversion for the bound user
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
`
|
|
65
|
-
|
|
67
|
+
`Assignment = { name: string | null; group: string | null; enrolled: boolean;
|
|
68
|
+
get(field, fallback) }`. Full reference:
|
|
69
|
+
<https://shipeasy-ai.github.io/sdk-ts/pages/experiments.md> · track snippet
|
|
66
70
|
<https://shipeasy-ai.github.io/sdk-ts/snippets/metrics/track.md>
|
|
67
71
|
|
|
68
72
|
## i18n
|
|
@@ -138,8 +142,8 @@ Full reference: <https://shipeasy-ai.github.io/sdk-ts/pages/openfeature.md>
|
|
|
138
142
|
## Advanced
|
|
139
143
|
|
|
140
144
|
`privateAttributes`, `bucketBy` (custom bucketing unit), `stickyBucketing`
|
|
141
|
-
(browser: on by default, `__se_sticky` cookie),
|
|
142
|
-
|
|
145
|
+
(browser: on by default, `__se_sticky` cookie), exposure control (browser:
|
|
146
|
+
`universe(name).assign({ logExposure: false })` + `disableAutoExposure`),
|
|
143
147
|
`onChange(cb)` (requires `configure({ poll: true })`) / browser `subscribe()`.
|
|
144
148
|
Devtools overlay: `Shift+Alt+S` or `?se=1`. Full reference:
|
|
145
149
|
<https://shipeasy-ai.github.io/sdk-ts/pages/advanced.md>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipeasy/sdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Shipeasy SDK — feature gates, runtime configs, experiments, and metrics for the Shipeasy hosted service.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://shipeasy.ai",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"main": "./dist/server/index.js",
|
|
15
15
|
"module": "./dist/server/index.mjs",
|
|
16
16
|
"browser": "./dist/client/index.js",
|
|
17
|
+
"react-native": "./dist/client/index.js",
|
|
17
18
|
"typesVersions": {
|
|
18
19
|
"*": {
|
|
19
20
|
"client": [
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
},
|
|
30
31
|
"exports": {
|
|
31
32
|
".": {
|
|
33
|
+
"react-native": "./dist/client/index.js",
|
|
32
34
|
"node": "./dist/server/index.js",
|
|
33
35
|
"browser": "./dist/client/index.js",
|
|
34
36
|
"default": "./dist/server/index.js"
|