@shipeasy/sdk 5.0.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -0
- package/dist/client/index.d.mts +169 -4
- package/dist/client/index.d.ts +169 -4
- package/dist/client/index.js +228 -26
- package/dist/client/index.mjs +227 -26
- package/dist/openfeature-server/index.d.mts +338 -0
- package/dist/openfeature-server/index.d.ts +338 -0
- package/dist/openfeature-server/index.js +125 -0
- package/dist/openfeature-server/index.mjs +100 -0
- package/dist/openfeature-web/index.d.mts +332 -0
- package/dist/openfeature-web/index.d.ts +332 -0
- package/dist/openfeature-web/index.js +120 -0
- package/dist/openfeature-web/index.mjs +95 -0
- package/dist/server/index.d.mts +198 -4
- package/dist/server/index.d.ts +198 -4
- package/dist/server/index.js +290 -36
- package/dist/server/index.mjs +294 -36
- package/package.json +23 -1
package/dist/server/index.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,25 @@ 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
|
+
];
|
|
376
|
+
function createInMemoryStickyStore(seed) {
|
|
377
|
+
const m = new Map(Object.entries(seed ?? {}));
|
|
378
|
+
return {
|
|
379
|
+
get: (unit) => m.get(unit),
|
|
380
|
+
set: (unit, exp, entry) => {
|
|
381
|
+
const cur = m.get(unit) ?? {};
|
|
382
|
+
cur[exp] = entry;
|
|
383
|
+
m.set(unit, cur);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
}
|
|
358
387
|
function isEnabled(v) {
|
|
359
388
|
return v === 1 || v === true;
|
|
360
389
|
}
|
|
@@ -407,6 +436,14 @@ var ANON_ID_RX = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
407
436
|
function mintAnonId() {
|
|
408
437
|
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `anon_${Math.random().toString(36).slice(2)}`;
|
|
409
438
|
}
|
|
439
|
+
function pickIdentifier(user, bucketBy) {
|
|
440
|
+
if (bucketBy) {
|
|
441
|
+
const v = user[bucketBy];
|
|
442
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
443
|
+
if (typeof v === "number") return String(v);
|
|
444
|
+
}
|
|
445
|
+
return user.user_id ?? user.anonymous_id;
|
|
446
|
+
}
|
|
410
447
|
function evalGateInternal(gate, user) {
|
|
411
448
|
if (isEnabled(gate.killswitch)) return false;
|
|
412
449
|
if (!isEnabled(gate.enabled)) return false;
|
|
@@ -462,10 +499,12 @@ function parseOverrides(rawUrl) {
|
|
|
462
499
|
}
|
|
463
500
|
return { gates, configs, experiments };
|
|
464
501
|
}
|
|
465
|
-
var FlagsClient = class {
|
|
502
|
+
var FlagsClient = class _FlagsClient {
|
|
466
503
|
apiKey;
|
|
467
504
|
baseUrl;
|
|
468
505
|
env;
|
|
506
|
+
privateAttributes;
|
|
507
|
+
stickyStore;
|
|
469
508
|
telemetry;
|
|
470
509
|
seeLimiter = new SeeLimiter();
|
|
471
510
|
flagsBlob = null;
|
|
@@ -475,47 +514,157 @@ var FlagsClient = class {
|
|
|
475
514
|
pollInterval = 30;
|
|
476
515
|
timer = null;
|
|
477
516
|
initialized = false;
|
|
517
|
+
// Test mode: built by `FlagsClient.forTesting()`. When set, init()/initOnce()
|
|
518
|
+
// never fetch, track() is a no-op, and telemetry is off — the client is a
|
|
519
|
+
// fully self-contained, network-free seam for unit tests.
|
|
520
|
+
testMode;
|
|
521
|
+
// Programmatic overrides (Statsig-style). Set on any client via
|
|
522
|
+
// overrideFlag/overrideConfig/overrideExperiment; they win over the fetched
|
|
523
|
+
// blob in getFlag/getConfig/getExperiment. Cleared by clearOverrides().
|
|
524
|
+
flagOverrides = /* @__PURE__ */ new Map();
|
|
525
|
+
configOverrides = /* @__PURE__ */ new Map();
|
|
526
|
+
experimentOverrides = /* @__PURE__ */ new Map();
|
|
527
|
+
// Change listeners fired after a background poll returns NEW data (200, not
|
|
528
|
+
// 304). Never fired in testMode/offline (no polling happens there).
|
|
529
|
+
changeListeners = /* @__PURE__ */ new Set();
|
|
478
530
|
constructor(opts) {
|
|
479
531
|
this.apiKey = opts.apiKey;
|
|
480
532
|
this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
|
|
481
533
|
this.env = opts.env ?? "prod";
|
|
534
|
+
this.privateAttributes = opts.privateAttributes ?? [];
|
|
535
|
+
this.stickyStore = opts.stickyStore;
|
|
536
|
+
this.testMode = opts.testMode === true;
|
|
482
537
|
this.telemetry = new Telemetry({
|
|
483
538
|
endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
|
|
484
539
|
sdkKey: this.apiKey,
|
|
485
540
|
side: "server",
|
|
486
541
|
env: this.env,
|
|
487
|
-
|
|
542
|
+
// Test mode never talks to the network — telemetry off regardless of opt.
|
|
543
|
+
disabled: this.testMode || opts.disableTelemetry
|
|
488
544
|
});
|
|
489
|
-
if (opts.initialBlob) {
|
|
490
|
-
this.flagsBlob = opts.initialBlob;
|
|
545
|
+
if (opts.initialBlob || this.testMode) {
|
|
546
|
+
this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
|
|
547
|
+
this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
|
|
491
548
|
this.initialized = true;
|
|
492
549
|
}
|
|
493
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* Build a no-network, immediately-usable client for tests (Statsig-style).
|
|
553
|
+
* init()/initOnce() are no-ops (never fetch), track() is a no-op, telemetry
|
|
554
|
+
* is disabled, and the client is already "initialized" — seed every entity
|
|
555
|
+
* with overrideFlag/overrideConfig/overrideExperiment. No SDK key required.
|
|
556
|
+
*
|
|
557
|
+
* ```ts
|
|
558
|
+
* const client = FlagsClient.forTesting();
|
|
559
|
+
* client.overrideFlag("new_checkout", true);
|
|
560
|
+
* client.getFlag("new_checkout", { user_id: "u1" }); // true
|
|
561
|
+
* ```
|
|
562
|
+
*/
|
|
563
|
+
static forTesting(opts) {
|
|
564
|
+
return new _FlagsClient({ apiKey: "", ...opts, testMode: true });
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Build a fully OFFLINE client from a pre-captured snapshot — no network ever.
|
|
568
|
+
* Reuses the test-mode plumbing (init()/initOnce()/track() are no-ops,
|
|
569
|
+
* telemetry off) but, unlike forTesting(), seeds the REAL flags + experiments
|
|
570
|
+
* blobs so evaluations run the canonical eval against the snapshot. Local
|
|
571
|
+
* overrides still apply on top.
|
|
572
|
+
*
|
|
573
|
+
* Snapshot shape mirrors the wire bodies:
|
|
574
|
+
* `{ flags: <GET /sdk/flags body>, experiments: <GET /sdk/experiments body> }`.
|
|
575
|
+
*/
|
|
576
|
+
static fromSnapshot(snapshot) {
|
|
577
|
+
const client = new _FlagsClient({ apiKey: "", testMode: true });
|
|
578
|
+
client.flagsBlob = snapshot.flags;
|
|
579
|
+
client.expsBlob = snapshot.experiments;
|
|
580
|
+
client.initialized = true;
|
|
581
|
+
return client;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Build a fully OFFLINE client from a snapshot JSON file on disk (Node only —
|
|
585
|
+
* not available in the browser entrypoint). The file must contain
|
|
586
|
+
* `{ "flags": <GET /sdk/flags body>, "experiments": <GET /sdk/experiments body> }`.
|
|
587
|
+
* See {@link FlagsClient.fromSnapshot}.
|
|
588
|
+
*/
|
|
589
|
+
static fromFile(path) {
|
|
590
|
+
const fs = __require("fs");
|
|
591
|
+
const raw = fs.readFileSync(path, "utf8");
|
|
592
|
+
const snapshot = JSON.parse(raw);
|
|
593
|
+
return _FlagsClient.fromSnapshot(snapshot);
|
|
594
|
+
}
|
|
494
595
|
async init() {
|
|
596
|
+
if (this.testMode) {
|
|
597
|
+
this.initialized = true;
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
495
600
|
await this.fetchAll();
|
|
496
601
|
this.initialized = true;
|
|
497
602
|
this.startPoll();
|
|
498
603
|
}
|
|
499
604
|
async initOnce() {
|
|
500
|
-
if (this.initialized) return;
|
|
605
|
+
if (this.testMode || this.initialized) return;
|
|
501
606
|
await this.fetchAll();
|
|
502
607
|
this.initialized = true;
|
|
503
608
|
}
|
|
609
|
+
// ---- Local overrides (Statsig-style) ----
|
|
610
|
+
/** Force `getFlag(name, …)` to return `value`, ignoring the fetched gate. */
|
|
611
|
+
overrideFlag(name, value) {
|
|
612
|
+
this.flagOverrides.set(name, value);
|
|
613
|
+
}
|
|
614
|
+
/** Force `getConfig(name)` to return `value`, ignoring the fetched config. */
|
|
615
|
+
overrideConfig(name, value) {
|
|
616
|
+
this.configOverrides.set(name, value);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
|
|
620
|
+
* ignoring allocation, holdouts, and targeting.
|
|
621
|
+
*/
|
|
622
|
+
overrideExperiment(name, group, params) {
|
|
623
|
+
this.experimentOverrides.set(name, { group, params });
|
|
624
|
+
}
|
|
625
|
+
/** Remove every programmatic override set via the override* methods. */
|
|
626
|
+
clearOverrides() {
|
|
627
|
+
this.flagOverrides.clear();
|
|
628
|
+
this.configOverrides.clear();
|
|
629
|
+
this.experimentOverrides.clear();
|
|
630
|
+
}
|
|
504
631
|
destroy() {
|
|
505
632
|
if (this.timer !== null) {
|
|
506
633
|
clearInterval(this.timer);
|
|
507
634
|
this.timer = null;
|
|
508
635
|
}
|
|
509
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* Subscribe to data-change notifications. The listener fires after a
|
|
639
|
+
* background poll fetch returns NEW data (200, not 304) — i.e. after the
|
|
640
|
+
* cached blob is updated. Never fires in testMode/offline (no polling).
|
|
641
|
+
* Returns an unsubscribe function.
|
|
642
|
+
*/
|
|
643
|
+
onChange(listener) {
|
|
644
|
+
this.changeListeners.add(listener);
|
|
645
|
+
return () => {
|
|
646
|
+
this.changeListeners.delete(listener);
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
notifyChange() {
|
|
650
|
+
for (const l of this.changeListeners) {
|
|
651
|
+
try {
|
|
652
|
+
l();
|
|
653
|
+
} catch (err) {
|
|
654
|
+
console.warn("[shipeasy] onChange listener threw:", String(err));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
510
658
|
startPoll() {
|
|
511
659
|
this.timer = setInterval(() => {
|
|
512
|
-
this.fetchAll().catch(
|
|
660
|
+
this.fetchAll(true).catch(
|
|
513
661
|
(err) => console.warn("[shipeasy] background poll failed:", String(err))
|
|
514
662
|
);
|
|
515
663
|
}, this.pollInterval * 1e3);
|
|
516
664
|
}
|
|
517
|
-
async fetchAll() {
|
|
518
|
-
const [
|
|
665
|
+
async fetchAll(fromPoll = false) {
|
|
666
|
+
const [flagsRes, expsChanged] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
|
|
667
|
+
const { interval, changed: flagsChanged } = flagsRes;
|
|
519
668
|
if (interval !== null && interval !== this.pollInterval) {
|
|
520
669
|
this.pollInterval = interval;
|
|
521
670
|
if (this.timer !== null) {
|
|
@@ -523,41 +672,72 @@ var FlagsClient = class {
|
|
|
523
672
|
this.startPoll();
|
|
524
673
|
}
|
|
525
674
|
}
|
|
675
|
+
if (fromPoll && (flagsChanged || expsChanged)) this.notifyChange();
|
|
526
676
|
}
|
|
527
677
|
async fetchFlags() {
|
|
528
678
|
const headers = { "X-SDK-Key": this.apiKey };
|
|
529
679
|
if (this.flagsEtag) headers["If-None-Match"] = this.flagsEtag;
|
|
530
680
|
const res = await globalThis.fetch(`${this.baseUrl}/sdk/flags?env=${this.env}`, { headers });
|
|
531
681
|
const interval = Number(res.headers.get("X-Poll-Interval") ?? "30") || 30;
|
|
532
|
-
if (res.status === 304) return interval;
|
|
682
|
+
if (res.status === 304) return { interval, changed: false };
|
|
533
683
|
if (!res.ok) throw new Error(`/sdk/flags returned ${res.status}`);
|
|
534
684
|
const etag = res.headers.get("ETag");
|
|
535
685
|
if (etag) this.flagsEtag = etag;
|
|
536
686
|
this.flagsBlob = await res.json();
|
|
537
|
-
return interval;
|
|
687
|
+
return { interval, changed: true };
|
|
538
688
|
}
|
|
539
689
|
async fetchExps() {
|
|
540
690
|
const headers = { "X-SDK-Key": this.apiKey };
|
|
541
691
|
if (this.expsEtag) headers["If-None-Match"] = this.expsEtag;
|
|
542
692
|
const res = await globalThis.fetch(`${this.baseUrl}/sdk/experiments`, { headers });
|
|
543
|
-
if (res.status === 304) return;
|
|
693
|
+
if (res.status === 304) return false;
|
|
544
694
|
if (!res.ok) throw new Error(`/sdk/experiments returned ${res.status}`);
|
|
545
695
|
const etag = res.headers.get("ETag");
|
|
546
696
|
if (etag) this.expsEtag = etag;
|
|
547
697
|
this.expsBlob = await res.json();
|
|
698
|
+
return true;
|
|
548
699
|
}
|
|
549
|
-
|
|
700
|
+
/**
|
|
701
|
+
* Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). The
|
|
702
|
+
* reason is computed entirely at this boundary — the canonical eval
|
|
703
|
+
* (evalGateInternal) is untouched. A local override short-circuits BEFORE
|
|
704
|
+
* telemetry, exactly like getFlag's override path; otherwise exactly one
|
|
705
|
+
* "gate" beacon is emitted.
|
|
706
|
+
*/
|
|
707
|
+
getFlagDetail(name, user) {
|
|
708
|
+
const ov = this.flagOverrides.get(name);
|
|
709
|
+
if (ov !== void 0) return { value: ov, reason: "OVERRIDE" };
|
|
550
710
|
this.telemetry.emit("gate", name);
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
711
|
+
if (!this.flagsBlob) return { value: false, reason: "CLIENT_NOT_READY" };
|
|
712
|
+
const gate = this.flagsBlob.gates[name];
|
|
713
|
+
if (!gate) return { value: false, reason: "FLAG_NOT_FOUND" };
|
|
714
|
+
if (isEnabled(gate.killswitch) || !isEnabled(gate.enabled)) {
|
|
715
|
+
return { value: false, reason: "OFF" };
|
|
716
|
+
}
|
|
717
|
+
const value = evalGateInternal(gate, user);
|
|
718
|
+
return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
|
|
554
719
|
}
|
|
555
|
-
|
|
720
|
+
/**
|
|
721
|
+
* Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
|
|
722
|
+
* evaluated (client not initialized or flag not found) — never for a gate that
|
|
723
|
+
* legitimately evaluates to false. Plain `getFlag(name, user)` keeps returning
|
|
724
|
+
* false for a missing flag.
|
|
725
|
+
*/
|
|
726
|
+
getFlag(name, user, defaultValue = false) {
|
|
727
|
+
const d = this.getFlagDetail(name, user);
|
|
728
|
+
if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
|
|
729
|
+
return d.value;
|
|
730
|
+
}
|
|
731
|
+
getConfig(name, decodeOrOpts) {
|
|
556
732
|
this.telemetry.emit("config", name);
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
733
|
+
const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
|
|
734
|
+
const has = this.configOverrides.has(name);
|
|
735
|
+
const raw = has ? this.configOverrides.get(name) : this.flagsBlob?.configs[name]?.value;
|
|
736
|
+
if (raw === void 0 && !has) {
|
|
737
|
+
return "defaultValue" in opts ? opts.defaultValue : void 0;
|
|
738
|
+
}
|
|
739
|
+
if (!opts.decode) return raw;
|
|
740
|
+
return opts.decode(raw);
|
|
561
741
|
}
|
|
562
742
|
getExperiment(name, user, defaultParams, decode) {
|
|
563
743
|
this.telemetry.emit("experiment", name);
|
|
@@ -566,6 +746,16 @@ var FlagsClient = class {
|
|
|
566
746
|
group: "control",
|
|
567
747
|
params: defaultParams
|
|
568
748
|
};
|
|
749
|
+
const ov = this.experimentOverrides.get(name);
|
|
750
|
+
if (ov) {
|
|
751
|
+
if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
|
|
752
|
+
try {
|
|
753
|
+
return { inExperiment: true, group: ov.group, params: decode(ov.params) };
|
|
754
|
+
} catch (err) {
|
|
755
|
+
console.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
|
|
756
|
+
return notIn;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
569
759
|
if (!this.flagsBlob || !this.expsBlob) return notIn;
|
|
570
760
|
const exp = this.expsBlob.experiments[name];
|
|
571
761
|
if (!exp || exp.status !== "running") return notIn;
|
|
@@ -573,7 +763,7 @@ var FlagsClient = class {
|
|
|
573
763
|
const gate = this.flagsBlob.gates[exp.targetingGate];
|
|
574
764
|
if (!gate || !evalGateInternal(gate, user)) return notIn;
|
|
575
765
|
}
|
|
576
|
-
const uid = user
|
|
766
|
+
const uid = pickIdentifier(user, exp.bucketBy);
|
|
577
767
|
if (!uid) return notIn;
|
|
578
768
|
const universe = this.expsBlob.universes[exp.universe];
|
|
579
769
|
const holdoutRange = universe?.holdout_range ?? null;
|
|
@@ -582,6 +772,23 @@ var FlagsClient = class {
|
|
|
582
772
|
const [lo, hi] = holdoutRange;
|
|
583
773
|
if (seg >= lo && seg <= hi) return notIn;
|
|
584
774
|
}
|
|
775
|
+
const asResult = (g) => {
|
|
776
|
+
if (!decode) return { inExperiment: true, group: g.name, params: g.params };
|
|
777
|
+
try {
|
|
778
|
+
return { inExperiment: true, group: g.name, params: decode(g.params) };
|
|
779
|
+
} catch (err) {
|
|
780
|
+
console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
|
|
781
|
+
return notIn;
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
const salt8 = exp.salt.slice(0, 8);
|
|
785
|
+
if (this.stickyStore) {
|
|
786
|
+
const entry = this.stickyStore.get(uid)?.[name];
|
|
787
|
+
if (entry && entry.s === salt8) {
|
|
788
|
+
const g = exp.groups.find((x) => x.name === entry.g);
|
|
789
|
+
if (g) return asResult(g);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
585
792
|
if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
|
|
586
793
|
const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
|
|
587
794
|
let cumulative = 0;
|
|
@@ -589,20 +796,24 @@ var FlagsClient = class {
|
|
|
589
796
|
const g = exp.groups[i];
|
|
590
797
|
cumulative += g.weight;
|
|
591
798
|
if (groupHash < cumulative || i === exp.groups.length - 1) {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
try {
|
|
596
|
-
return { inExperiment: true, group: g.name, params: decode(g.params) };
|
|
597
|
-
} catch (err) {
|
|
598
|
-
console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
|
|
599
|
-
return notIn;
|
|
600
|
-
}
|
|
799
|
+
this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
|
|
800
|
+
return asResult(g);
|
|
601
801
|
}
|
|
602
802
|
}
|
|
603
803
|
return notIn;
|
|
604
804
|
}
|
|
805
|
+
/** Drop caller-marked private attributes from an outbound props bag. */
|
|
806
|
+
stripPrivate(props) {
|
|
807
|
+
if (!props || this.privateAttributes.length === 0) return props;
|
|
808
|
+
const out = {};
|
|
809
|
+
for (const [k, v] of Object.entries(props)) {
|
|
810
|
+
if (!this.privateAttributes.includes(k)) out[k] = v;
|
|
811
|
+
}
|
|
812
|
+
return out;
|
|
813
|
+
}
|
|
605
814
|
track(userId, eventName, props) {
|
|
815
|
+
if (this.testMode) return;
|
|
816
|
+
const safeProps = this.stripPrivate(props);
|
|
606
817
|
const body = JSON.stringify({
|
|
607
818
|
events: [
|
|
608
819
|
{
|
|
@@ -610,7 +821,7 @@ var FlagsClient = class {
|
|
|
610
821
|
event_name: eventName,
|
|
611
822
|
user_id: userId,
|
|
612
823
|
ts: Date.now(),
|
|
613
|
-
...
|
|
824
|
+
...safeProps !== void 0 ? { properties: safeProps } : {}
|
|
614
825
|
}
|
|
615
826
|
]
|
|
616
827
|
});
|
|
@@ -620,6 +831,37 @@ var FlagsClient = class {
|
|
|
620
831
|
body
|
|
621
832
|
}).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
|
|
622
833
|
}
|
|
834
|
+
/**
|
|
835
|
+
* Emit an exposure event for an experiment at the server-side decision point
|
|
836
|
+
* (parity with the browser's auto-exposure). The server is stateless and
|
|
837
|
+
* never auto-logs, so call this when you actually present the treatment.
|
|
838
|
+
* Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
|
|
839
|
+
* as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
|
|
840
|
+
* `/collect`. No-op in test mode or when the user isn't enrolled.
|
|
841
|
+
*/
|
|
842
|
+
logExposure(user, name) {
|
|
843
|
+
if (this.testMode) return;
|
|
844
|
+
const u = typeof user === "string" ? { user_id: user } : user;
|
|
845
|
+
const result = this.getExperiment(name, u, {});
|
|
846
|
+
if (!result.inExperiment) return;
|
|
847
|
+
const body = JSON.stringify({
|
|
848
|
+
events: [
|
|
849
|
+
{
|
|
850
|
+
type: "exposure",
|
|
851
|
+
experiment: name,
|
|
852
|
+
group: result.group,
|
|
853
|
+
...u.user_id !== void 0 ? { user_id: u.user_id } : {},
|
|
854
|
+
...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
|
|
855
|
+
ts: Date.now()
|
|
856
|
+
}
|
|
857
|
+
]
|
|
858
|
+
});
|
|
859
|
+
globalThis.fetch(`${this.baseUrl}/collect`, {
|
|
860
|
+
method: "POST",
|
|
861
|
+
headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
|
|
862
|
+
body
|
|
863
|
+
}).catch((err) => console.warn("[shipeasy] logExposure failed:", String(err)));
|
|
864
|
+
}
|
|
623
865
|
/**
|
|
624
866
|
* Report a structured error into the errors primitive. Fire-and-forget —
|
|
625
867
|
* never blocks or throws into the request path. Spam-guarded by a 30s
|
|
@@ -826,7 +1068,11 @@ async function shipeasy(opts) {
|
|
|
826
1068
|
);
|
|
827
1069
|
}
|
|
828
1070
|
const profile = opts.i18nDefaultProfile ?? "en:prod";
|
|
829
|
-
flags.configure({
|
|
1071
|
+
flags.configure({
|
|
1072
|
+
apiKey: serverKey,
|
|
1073
|
+
disableTelemetry: opts.disableTelemetry,
|
|
1074
|
+
privateAttributes: opts.privateAttributes
|
|
1075
|
+
});
|
|
830
1076
|
let resolvedUrlOverrides = opts.urlOverrides;
|
|
831
1077
|
if (!resolvedUrlOverrides) {
|
|
832
1078
|
try {
|
|
@@ -937,11 +1183,15 @@ var flags = {
|
|
|
937
1183
|
destroy() {
|
|
938
1184
|
_server?.destroy();
|
|
939
1185
|
},
|
|
940
|
-
get(name, user) {
|
|
941
|
-
return _server?.getFlag(name, user) ??
|
|
1186
|
+
get(name, user, defaultValue = false) {
|
|
1187
|
+
return _server?.getFlag(name, user, defaultValue) ?? defaultValue;
|
|
942
1188
|
},
|
|
943
|
-
|
|
944
|
-
|
|
1189
|
+
/** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
|
|
1190
|
+
getDetail(name, user) {
|
|
1191
|
+
return _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" };
|
|
1192
|
+
},
|
|
1193
|
+
getConfig(name, decodeOrOpts) {
|
|
1194
|
+
return _server?.getConfig(name, decodeOrOpts);
|
|
945
1195
|
},
|
|
946
1196
|
getExperiment(name, user, defaultParams, decode) {
|
|
947
1197
|
return _server?.getExperiment(name, user, defaultParams, decode) ?? {
|
|
@@ -961,6 +1211,11 @@ var flags = {
|
|
|
961
1211
|
track(userId, eventName, props) {
|
|
962
1212
|
_server?.track(userId, eventName, props);
|
|
963
1213
|
},
|
|
1214
|
+
/** Emit an exposure for an enrolled experiment at the decision point. See
|
|
1215
|
+
* {@link FlagsClient.logExposure}. No-op before configure(). */
|
|
1216
|
+
logExposure(user, name) {
|
|
1217
|
+
_server?.logExposure(user, name);
|
|
1218
|
+
},
|
|
964
1219
|
/**
|
|
965
1220
|
* Evaluate all flags / configs / experiments for a user against the locally
|
|
966
1221
|
* cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
|
|
@@ -991,9 +1246,12 @@ var see = Object.assign(
|
|
|
991
1246
|
);
|
|
992
1247
|
export {
|
|
993
1248
|
ANON_ID_COOKIE,
|
|
1249
|
+
FLAG_REASONS,
|
|
994
1250
|
FlagsClient,
|
|
1251
|
+
_murmur3ForTests,
|
|
995
1252
|
_resetShipeasyServerForTests,
|
|
996
1253
|
configureShipeasyServer,
|
|
1254
|
+
createInMemoryStickyStore,
|
|
997
1255
|
fetchLabelsForSSR,
|
|
998
1256
|
flags,
|
|
999
1257
|
getBootstrapHtml,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipeasy/sdk",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.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",
|
|
@@ -47,12 +47,24 @@
|
|
|
47
47
|
"import": "./dist/next/index.mjs",
|
|
48
48
|
"default": "./dist/next/index.js"
|
|
49
49
|
},
|
|
50
|
+
"./openfeature-server": {
|
|
51
|
+
"types": "./dist/openfeature-server/index.d.ts",
|
|
52
|
+
"import": "./dist/openfeature-server/index.mjs",
|
|
53
|
+
"default": "./dist/openfeature-server/index.js"
|
|
54
|
+
},
|
|
55
|
+
"./openfeature-web": {
|
|
56
|
+
"types": "./dist/openfeature-web/index.d.ts",
|
|
57
|
+
"import": "./dist/openfeature-web/index.mjs",
|
|
58
|
+
"default": "./dist/openfeature-web/index.js"
|
|
59
|
+
},
|
|
50
60
|
"./templates/*": "./templates/*.js"
|
|
51
61
|
},
|
|
52
62
|
"files": [
|
|
53
63
|
"dist/server/",
|
|
54
64
|
"dist/client/",
|
|
55
65
|
"dist/next/",
|
|
66
|
+
"dist/openfeature-server/",
|
|
67
|
+
"dist/openfeature-web/",
|
|
56
68
|
"templates/",
|
|
57
69
|
"LICENSE",
|
|
58
70
|
"README.md"
|
|
@@ -68,14 +80,24 @@
|
|
|
68
80
|
"murmurhash-js": "^1.0.0"
|
|
69
81
|
},
|
|
70
82
|
"peerDependencies": {
|
|
83
|
+
"@openfeature/server-sdk": ">=1.13.0",
|
|
84
|
+
"@openfeature/web-sdk": ">=1.2.0",
|
|
71
85
|
"next": ">=13"
|
|
72
86
|
},
|
|
73
87
|
"peerDependenciesMeta": {
|
|
88
|
+
"@openfeature/server-sdk": {
|
|
89
|
+
"optional": true
|
|
90
|
+
},
|
|
91
|
+
"@openfeature/web-sdk": {
|
|
92
|
+
"optional": true
|
|
93
|
+
},
|
|
74
94
|
"next": {
|
|
75
95
|
"optional": true
|
|
76
96
|
}
|
|
77
97
|
},
|
|
78
98
|
"devDependencies": {
|
|
99
|
+
"@openfeature/server-sdk": "^1.22.0",
|
|
100
|
+
"@openfeature/web-sdk": "^1.9.0",
|
|
79
101
|
"@types/murmurhash-js": "^1.0.6",
|
|
80
102
|
"@types/node": "^20.0.0",
|
|
81
103
|
"next": "^16.2.3",
|