@shipeasy/sdk 5.1.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/dist/client/index.d.mts +86 -2
- package/dist/client/index.d.ts +86 -2
- package/dist/client/index.js +90 -7
- package/dist/client/index.mjs +90 -7
- 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 +58 -1
- package/dist/server/index.d.ts +58 -1
- package/dist/server/index.js +97 -12
- package/dist/server/index.mjs +96 -12
- package/package.json +23 -1
package/dist/server/index.d.ts
CHANGED
|
@@ -170,7 +170,27 @@ interface Experiment {
|
|
|
170
170
|
salt: string;
|
|
171
171
|
groups: ExperimentGroup[];
|
|
172
172
|
status: "draft" | "running" | "stopped" | "archived";
|
|
173
|
+
/** Attribute to bucket on (e.g. company_id); defaults to user_id/anonymous_id. */
|
|
174
|
+
bucketBy?: string | null;
|
|
173
175
|
}
|
|
176
|
+
/** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
|
|
177
|
+
interface StickyEntry {
|
|
178
|
+
g: string;
|
|
179
|
+
s: string;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Pluggable sticky-bucketing store for the server (doc 20 §2). Keyed by the
|
|
183
|
+
* bucketing unit; the value is that unit's per-experiment assignments. Absent
|
|
184
|
+
* from {@link FlagsClientOptions} ⇒ today's deterministic behaviour. Use
|
|
185
|
+
* {@link createInMemoryStickyStore} or a cookie-bridge built from request
|
|
186
|
+
* cookies.
|
|
187
|
+
*/
|
|
188
|
+
interface StickyBucketStore {
|
|
189
|
+
get(unit: string): Record<string, StickyEntry> | undefined;
|
|
190
|
+
set(unit: string, exp: string, entry: StickyEntry): void;
|
|
191
|
+
}
|
|
192
|
+
/** A process-local sticky store (Map-backed). Handy for tests + single-process servers. */
|
|
193
|
+
declare function createInMemoryStickyStore(seed?: Record<string, Record<string, StickyEntry>>): StickyBucketStore;
|
|
174
194
|
interface Universe {
|
|
175
195
|
holdout_range: [number, number] | null;
|
|
176
196
|
}
|
|
@@ -223,6 +243,21 @@ interface FlagsClientOptions {
|
|
|
223
243
|
disableTelemetry?: boolean;
|
|
224
244
|
/** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
|
|
225
245
|
telemetryUrl?: string;
|
|
246
|
+
/**
|
|
247
|
+
* Attribute names usable for targeting but never persisted in analytics
|
|
248
|
+
* (LD/Statsig `privateAttributes`). The server evaluates locally so private
|
|
249
|
+
* attrs never leave for evaluation at all; the only egress is `/collect`, and
|
|
250
|
+
* the listed keys are stripped from every outbound `track()` payload.
|
|
251
|
+
*/
|
|
252
|
+
privateAttributes?: string[];
|
|
253
|
+
/**
|
|
254
|
+
* Sticky-bucketing store (doc 20 §2). When provided, `getExperiment` locks a
|
|
255
|
+
* unit to its first-assigned variant — changing allocation % or weights won't
|
|
256
|
+
* re-bucket enrolled units (changing the experiment salt is the reshuffle
|
|
257
|
+
* lever). Absent ⇒ deterministic (fully backward compatible). Built-ins:
|
|
258
|
+
* {@link createInMemoryStickyStore}, or a cookie-bridge over `__se_sticky`.
|
|
259
|
+
*/
|
|
260
|
+
stickyStore?: StickyBucketStore;
|
|
226
261
|
/**
|
|
227
262
|
* Test mode — no network at all. init()/initOnce() are no-ops (never fetch),
|
|
228
263
|
* track() is a no-op, telemetry is forced off, and the client starts
|
|
@@ -235,6 +270,8 @@ declare class FlagsClient {
|
|
|
235
270
|
private readonly apiKey;
|
|
236
271
|
private readonly baseUrl;
|
|
237
272
|
private readonly env;
|
|
273
|
+
private readonly privateAttributes;
|
|
274
|
+
private readonly stickyStore;
|
|
238
275
|
private readonly telemetry;
|
|
239
276
|
private readonly seeLimiter;
|
|
240
277
|
private flagsBlob;
|
|
@@ -328,7 +365,18 @@ declare class FlagsClient {
|
|
|
328
365
|
getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
|
|
329
366
|
getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
|
|
330
367
|
getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
|
|
368
|
+
/** Drop caller-marked private attributes from an outbound props bag. */
|
|
369
|
+
private stripPrivate;
|
|
331
370
|
track(userId: string, eventName: string, props?: Record<string, unknown>): void;
|
|
371
|
+
/**
|
|
372
|
+
* Emit an exposure event for an experiment at the server-side decision point
|
|
373
|
+
* (parity with the browser's auto-exposure). The server is stateless and
|
|
374
|
+
* never auto-logs, so call this when you actually present the treatment.
|
|
375
|
+
* Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
|
|
376
|
+
* as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
|
|
377
|
+
* `/collect`. No-op in test mode or when the user isn't enrolled.
|
|
378
|
+
*/
|
|
379
|
+
logExposure(user: string | User, name: string): void;
|
|
332
380
|
/**
|
|
333
381
|
* Report a structured error into the errors primitive. Fire-and-forget —
|
|
334
382
|
* never blocks or throws into the request path. Spam-guarded by a 30s
|
|
@@ -418,6 +466,12 @@ interface ShipeasyServerConfig {
|
|
|
418
466
|
* that evaluate many flags per request. See {@link FlagsClientOptions.disableTelemetry}.
|
|
419
467
|
*/
|
|
420
468
|
disableTelemetry?: boolean;
|
|
469
|
+
/**
|
|
470
|
+
* Attribute names usable for targeting but never persisted in analytics
|
|
471
|
+
* (LD/Statsig `privateAttributes`). Stripped from every outbound `track()`
|
|
472
|
+
* payload. See {@link FlagsClientOptions.privateAttributes}.
|
|
473
|
+
*/
|
|
474
|
+
privateAttributes?: string[];
|
|
421
475
|
}
|
|
422
476
|
interface ShipeasyServerHandle {
|
|
423
477
|
flags: Record<string, boolean>;
|
|
@@ -485,6 +539,9 @@ declare const flags: {
|
|
|
485
539
|
*/
|
|
486
540
|
ks(name: string, switchKey?: string): boolean;
|
|
487
541
|
track(userId: string, eventName: string, props?: Record<string, unknown>): void;
|
|
542
|
+
/** Emit an exposure for an enrolled experiment at the decision point. See
|
|
543
|
+
* {@link FlagsClient.logExposure}. No-op before configure(). */
|
|
544
|
+
logExposure(user: string | User, name: string): void;
|
|
488
545
|
/**
|
|
489
546
|
* Evaluate all flags / configs / experiments for a user against the locally
|
|
490
547
|
* cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
|
|
@@ -543,4 +600,4 @@ interface SeeApi {
|
|
|
543
600
|
*/
|
|
544
601
|
declare const see: SeeApi;
|
|
545
602
|
|
|
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 };
|
|
603
|
+
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 StickyBucketStore, type StickyEntry, type User, type Violation, _murmur3ForTests, _resetShipeasyServerForTests, configureShipeasyServer, createInMemoryStickyStore, fetchLabelsForSSR, flags, getBootstrapHtml, getShipeasyServerClient, i18n, isExpected, see, seeContext, shipeasy, version };
|
package/dist/server/index.js
CHANGED
|
@@ -36,6 +36,7 @@ __export(server_exports, {
|
|
|
36
36
|
_murmur3ForTests: () => _murmur3ForTests,
|
|
37
37
|
_resetShipeasyServerForTests: () => _resetShipeasyServerForTests,
|
|
38
38
|
configureShipeasyServer: () => configureShipeasyServer,
|
|
39
|
+
createInMemoryStickyStore: () => createInMemoryStickyStore,
|
|
39
40
|
fetchLabelsForSSR: () => fetchLabelsForSSR,
|
|
40
41
|
flags: () => flags,
|
|
41
42
|
getBootstrapHtml: () => getBootstrapHtml,
|
|
@@ -415,6 +416,17 @@ var FLAG_REASONS = [
|
|
|
415
416
|
"RULE_MATCH",
|
|
416
417
|
"DEFAULT"
|
|
417
418
|
];
|
|
419
|
+
function createInMemoryStickyStore(seed) {
|
|
420
|
+
const m = new Map(Object.entries(seed ?? {}));
|
|
421
|
+
return {
|
|
422
|
+
get: (unit) => m.get(unit),
|
|
423
|
+
set: (unit, exp, entry) => {
|
|
424
|
+
const cur = m.get(unit) ?? {};
|
|
425
|
+
cur[exp] = entry;
|
|
426
|
+
m.set(unit, cur);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
418
430
|
function isEnabled(v) {
|
|
419
431
|
return v === 1 || v === true;
|
|
420
432
|
}
|
|
@@ -467,6 +479,14 @@ var ANON_ID_RX = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
467
479
|
function mintAnonId() {
|
|
468
480
|
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `anon_${Math.random().toString(36).slice(2)}`;
|
|
469
481
|
}
|
|
482
|
+
function pickIdentifier(user, bucketBy) {
|
|
483
|
+
if (bucketBy) {
|
|
484
|
+
const v = user[bucketBy];
|
|
485
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
486
|
+
if (typeof v === "number") return String(v);
|
|
487
|
+
}
|
|
488
|
+
return user.user_id ?? user.anonymous_id;
|
|
489
|
+
}
|
|
470
490
|
function evalGateInternal(gate, user) {
|
|
471
491
|
if (isEnabled(gate.killswitch)) return false;
|
|
472
492
|
if (!isEnabled(gate.enabled)) return false;
|
|
@@ -526,6 +546,8 @@ var FlagsClient = class _FlagsClient {
|
|
|
526
546
|
apiKey;
|
|
527
547
|
baseUrl;
|
|
528
548
|
env;
|
|
549
|
+
privateAttributes;
|
|
550
|
+
stickyStore;
|
|
529
551
|
telemetry;
|
|
530
552
|
seeLimiter = new SeeLimiter();
|
|
531
553
|
flagsBlob = null;
|
|
@@ -552,6 +574,8 @@ var FlagsClient = class _FlagsClient {
|
|
|
552
574
|
this.apiKey = opts.apiKey;
|
|
553
575
|
this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
|
|
554
576
|
this.env = opts.env ?? "prod";
|
|
577
|
+
this.privateAttributes = opts.privateAttributes ?? [];
|
|
578
|
+
this.stickyStore = opts.stickyStore;
|
|
555
579
|
this.testMode = opts.testMode === true;
|
|
556
580
|
this.telemetry = new Telemetry({
|
|
557
581
|
endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
|
|
@@ -782,7 +806,7 @@ var FlagsClient = class _FlagsClient {
|
|
|
782
806
|
const gate = this.flagsBlob.gates[exp.targetingGate];
|
|
783
807
|
if (!gate || !evalGateInternal(gate, user)) return notIn;
|
|
784
808
|
}
|
|
785
|
-
const uid = user
|
|
809
|
+
const uid = pickIdentifier(user, exp.bucketBy);
|
|
786
810
|
if (!uid) return notIn;
|
|
787
811
|
const universe = this.expsBlob.universes[exp.universe];
|
|
788
812
|
const holdoutRange = universe?.holdout_range ?? null;
|
|
@@ -791,6 +815,23 @@ var FlagsClient = class _FlagsClient {
|
|
|
791
815
|
const [lo, hi] = holdoutRange;
|
|
792
816
|
if (seg >= lo && seg <= hi) return notIn;
|
|
793
817
|
}
|
|
818
|
+
const asResult = (g) => {
|
|
819
|
+
if (!decode) return { inExperiment: true, group: g.name, params: g.params };
|
|
820
|
+
try {
|
|
821
|
+
return { inExperiment: true, group: g.name, params: decode(g.params) };
|
|
822
|
+
} catch (err) {
|
|
823
|
+
console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
|
|
824
|
+
return notIn;
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
const salt8 = exp.salt.slice(0, 8);
|
|
828
|
+
if (this.stickyStore) {
|
|
829
|
+
const entry = this.stickyStore.get(uid)?.[name];
|
|
830
|
+
if (entry && entry.s === salt8) {
|
|
831
|
+
const g = exp.groups.find((x) => x.name === entry.g);
|
|
832
|
+
if (g) return asResult(g);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
794
835
|
if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
|
|
795
836
|
const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
|
|
796
837
|
let cumulative = 0;
|
|
@@ -798,21 +839,24 @@ var FlagsClient = class _FlagsClient {
|
|
|
798
839
|
const g = exp.groups[i];
|
|
799
840
|
cumulative += g.weight;
|
|
800
841
|
if (groupHash < cumulative || i === exp.groups.length - 1) {
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
try {
|
|
805
|
-
return { inExperiment: true, group: g.name, params: decode(g.params) };
|
|
806
|
-
} catch (err) {
|
|
807
|
-
console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
|
|
808
|
-
return notIn;
|
|
809
|
-
}
|
|
842
|
+
this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
|
|
843
|
+
return asResult(g);
|
|
810
844
|
}
|
|
811
845
|
}
|
|
812
846
|
return notIn;
|
|
813
847
|
}
|
|
848
|
+
/** Drop caller-marked private attributes from an outbound props bag. */
|
|
849
|
+
stripPrivate(props) {
|
|
850
|
+
if (!props || this.privateAttributes.length === 0) return props;
|
|
851
|
+
const out = {};
|
|
852
|
+
for (const [k, v] of Object.entries(props)) {
|
|
853
|
+
if (!this.privateAttributes.includes(k)) out[k] = v;
|
|
854
|
+
}
|
|
855
|
+
return out;
|
|
856
|
+
}
|
|
814
857
|
track(userId, eventName, props) {
|
|
815
858
|
if (this.testMode) return;
|
|
859
|
+
const safeProps = this.stripPrivate(props);
|
|
816
860
|
const body = JSON.stringify({
|
|
817
861
|
events: [
|
|
818
862
|
{
|
|
@@ -820,7 +864,7 @@ var FlagsClient = class _FlagsClient {
|
|
|
820
864
|
event_name: eventName,
|
|
821
865
|
user_id: userId,
|
|
822
866
|
ts: Date.now(),
|
|
823
|
-
...
|
|
867
|
+
...safeProps !== void 0 ? { properties: safeProps } : {}
|
|
824
868
|
}
|
|
825
869
|
]
|
|
826
870
|
});
|
|
@@ -830,6 +874,37 @@ var FlagsClient = class _FlagsClient {
|
|
|
830
874
|
body
|
|
831
875
|
}).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
|
|
832
876
|
}
|
|
877
|
+
/**
|
|
878
|
+
* Emit an exposure event for an experiment at the server-side decision point
|
|
879
|
+
* (parity with the browser's auto-exposure). The server is stateless and
|
|
880
|
+
* never auto-logs, so call this when you actually present the treatment.
|
|
881
|
+
* Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
|
|
882
|
+
* as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
|
|
883
|
+
* `/collect`. No-op in test mode or when the user isn't enrolled.
|
|
884
|
+
*/
|
|
885
|
+
logExposure(user, name) {
|
|
886
|
+
if (this.testMode) return;
|
|
887
|
+
const u = typeof user === "string" ? { user_id: user } : user;
|
|
888
|
+
const result = this.getExperiment(name, u, {});
|
|
889
|
+
if (!result.inExperiment) return;
|
|
890
|
+
const body = JSON.stringify({
|
|
891
|
+
events: [
|
|
892
|
+
{
|
|
893
|
+
type: "exposure",
|
|
894
|
+
experiment: name,
|
|
895
|
+
group: result.group,
|
|
896
|
+
...u.user_id !== void 0 ? { user_id: u.user_id } : {},
|
|
897
|
+
...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
|
|
898
|
+
ts: Date.now()
|
|
899
|
+
}
|
|
900
|
+
]
|
|
901
|
+
});
|
|
902
|
+
globalThis.fetch(`${this.baseUrl}/collect`, {
|
|
903
|
+
method: "POST",
|
|
904
|
+
headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
|
|
905
|
+
body
|
|
906
|
+
}).catch((err) => console.warn("[shipeasy] logExposure failed:", String(err)));
|
|
907
|
+
}
|
|
833
908
|
/**
|
|
834
909
|
* Report a structured error into the errors primitive. Fire-and-forget —
|
|
835
910
|
* never blocks or throws into the request path. Spam-guarded by a 30s
|
|
@@ -1036,7 +1111,11 @@ async function shipeasy(opts) {
|
|
|
1036
1111
|
);
|
|
1037
1112
|
}
|
|
1038
1113
|
const profile = opts.i18nDefaultProfile ?? "en:prod";
|
|
1039
|
-
flags.configure({
|
|
1114
|
+
flags.configure({
|
|
1115
|
+
apiKey: serverKey,
|
|
1116
|
+
disableTelemetry: opts.disableTelemetry,
|
|
1117
|
+
privateAttributes: opts.privateAttributes
|
|
1118
|
+
});
|
|
1040
1119
|
let resolvedUrlOverrides = opts.urlOverrides;
|
|
1041
1120
|
if (!resolvedUrlOverrides) {
|
|
1042
1121
|
try {
|
|
@@ -1175,6 +1254,11 @@ var flags = {
|
|
|
1175
1254
|
track(userId, eventName, props) {
|
|
1176
1255
|
_server?.track(userId, eventName, props);
|
|
1177
1256
|
},
|
|
1257
|
+
/** Emit an exposure for an enrolled experiment at the decision point. See
|
|
1258
|
+
* {@link FlagsClient.logExposure}. No-op before configure(). */
|
|
1259
|
+
logExposure(user, name) {
|
|
1260
|
+
_server?.logExposure(user, name);
|
|
1261
|
+
},
|
|
1178
1262
|
/**
|
|
1179
1263
|
* Evaluate all flags / configs / experiments for a user against the locally
|
|
1180
1264
|
* cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
|
|
@@ -1211,6 +1295,7 @@ var see = Object.assign(
|
|
|
1211
1295
|
_murmur3ForTests,
|
|
1212
1296
|
_resetShipeasyServerForTests,
|
|
1213
1297
|
configureShipeasyServer,
|
|
1298
|
+
createInMemoryStickyStore,
|
|
1214
1299
|
fetchLabelsForSSR,
|
|
1215
1300
|
flags,
|
|
1216
1301
|
getBootstrapHtml,
|
package/dist/server/index.mjs
CHANGED
|
@@ -373,6 +373,17 @@ var FLAG_REASONS = [
|
|
|
373
373
|
"RULE_MATCH",
|
|
374
374
|
"DEFAULT"
|
|
375
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
|
+
}
|
|
376
387
|
function isEnabled(v) {
|
|
377
388
|
return v === 1 || v === true;
|
|
378
389
|
}
|
|
@@ -425,6 +436,14 @@ var ANON_ID_RX = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
425
436
|
function mintAnonId() {
|
|
426
437
|
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `anon_${Math.random().toString(36).slice(2)}`;
|
|
427
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
|
+
}
|
|
428
447
|
function evalGateInternal(gate, user) {
|
|
429
448
|
if (isEnabled(gate.killswitch)) return false;
|
|
430
449
|
if (!isEnabled(gate.enabled)) return false;
|
|
@@ -484,6 +503,8 @@ var FlagsClient = class _FlagsClient {
|
|
|
484
503
|
apiKey;
|
|
485
504
|
baseUrl;
|
|
486
505
|
env;
|
|
506
|
+
privateAttributes;
|
|
507
|
+
stickyStore;
|
|
487
508
|
telemetry;
|
|
488
509
|
seeLimiter = new SeeLimiter();
|
|
489
510
|
flagsBlob = null;
|
|
@@ -510,6 +531,8 @@ var FlagsClient = class _FlagsClient {
|
|
|
510
531
|
this.apiKey = opts.apiKey;
|
|
511
532
|
this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
|
|
512
533
|
this.env = opts.env ?? "prod";
|
|
534
|
+
this.privateAttributes = opts.privateAttributes ?? [];
|
|
535
|
+
this.stickyStore = opts.stickyStore;
|
|
513
536
|
this.testMode = opts.testMode === true;
|
|
514
537
|
this.telemetry = new Telemetry({
|
|
515
538
|
endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
|
|
@@ -740,7 +763,7 @@ var FlagsClient = class _FlagsClient {
|
|
|
740
763
|
const gate = this.flagsBlob.gates[exp.targetingGate];
|
|
741
764
|
if (!gate || !evalGateInternal(gate, user)) return notIn;
|
|
742
765
|
}
|
|
743
|
-
const uid = user
|
|
766
|
+
const uid = pickIdentifier(user, exp.bucketBy);
|
|
744
767
|
if (!uid) return notIn;
|
|
745
768
|
const universe = this.expsBlob.universes[exp.universe];
|
|
746
769
|
const holdoutRange = universe?.holdout_range ?? null;
|
|
@@ -749,6 +772,23 @@ var FlagsClient = class _FlagsClient {
|
|
|
749
772
|
const [lo, hi] = holdoutRange;
|
|
750
773
|
if (seg >= lo && seg <= hi) return notIn;
|
|
751
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
|
+
}
|
|
752
792
|
if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
|
|
753
793
|
const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
|
|
754
794
|
let cumulative = 0;
|
|
@@ -756,21 +796,24 @@ var FlagsClient = class _FlagsClient {
|
|
|
756
796
|
const g = exp.groups[i];
|
|
757
797
|
cumulative += g.weight;
|
|
758
798
|
if (groupHash < cumulative || i === exp.groups.length - 1) {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}
|
|
762
|
-
try {
|
|
763
|
-
return { inExperiment: true, group: g.name, params: decode(g.params) };
|
|
764
|
-
} catch (err) {
|
|
765
|
-
console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
|
|
766
|
-
return notIn;
|
|
767
|
-
}
|
|
799
|
+
this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
|
|
800
|
+
return asResult(g);
|
|
768
801
|
}
|
|
769
802
|
}
|
|
770
803
|
return notIn;
|
|
771
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
|
+
}
|
|
772
814
|
track(userId, eventName, props) {
|
|
773
815
|
if (this.testMode) return;
|
|
816
|
+
const safeProps = this.stripPrivate(props);
|
|
774
817
|
const body = JSON.stringify({
|
|
775
818
|
events: [
|
|
776
819
|
{
|
|
@@ -778,7 +821,7 @@ var FlagsClient = class _FlagsClient {
|
|
|
778
821
|
event_name: eventName,
|
|
779
822
|
user_id: userId,
|
|
780
823
|
ts: Date.now(),
|
|
781
|
-
...
|
|
824
|
+
...safeProps !== void 0 ? { properties: safeProps } : {}
|
|
782
825
|
}
|
|
783
826
|
]
|
|
784
827
|
});
|
|
@@ -788,6 +831,37 @@ var FlagsClient = class _FlagsClient {
|
|
|
788
831
|
body
|
|
789
832
|
}).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
|
|
790
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
|
+
}
|
|
791
865
|
/**
|
|
792
866
|
* Report a structured error into the errors primitive. Fire-and-forget —
|
|
793
867
|
* never blocks or throws into the request path. Spam-guarded by a 30s
|
|
@@ -994,7 +1068,11 @@ async function shipeasy(opts) {
|
|
|
994
1068
|
);
|
|
995
1069
|
}
|
|
996
1070
|
const profile = opts.i18nDefaultProfile ?? "en:prod";
|
|
997
|
-
flags.configure({
|
|
1071
|
+
flags.configure({
|
|
1072
|
+
apiKey: serverKey,
|
|
1073
|
+
disableTelemetry: opts.disableTelemetry,
|
|
1074
|
+
privateAttributes: opts.privateAttributes
|
|
1075
|
+
});
|
|
998
1076
|
let resolvedUrlOverrides = opts.urlOverrides;
|
|
999
1077
|
if (!resolvedUrlOverrides) {
|
|
1000
1078
|
try {
|
|
@@ -1133,6 +1211,11 @@ var flags = {
|
|
|
1133
1211
|
track(userId, eventName, props) {
|
|
1134
1212
|
_server?.track(userId, eventName, props);
|
|
1135
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
|
+
},
|
|
1136
1219
|
/**
|
|
1137
1220
|
* Evaluate all flags / configs / experiments for a user against the locally
|
|
1138
1221
|
* cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
|
|
@@ -1168,6 +1251,7 @@ export {
|
|
|
1168
1251
|
_murmur3ForTests,
|
|
1169
1252
|
_resetShipeasyServerForTests,
|
|
1170
1253
|
configureShipeasyServer,
|
|
1254
|
+
createInMemoryStickyStore,
|
|
1171
1255
|
fetchLabelsForSSR,
|
|
1172
1256
|
flags,
|
|
1173
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",
|