@shipeasy/sdk 5.0.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -0
- package/dist/client/index.d.mts +84 -3
- package/dist/client/index.d.ts +84 -3
- package/dist/client/index.js +138 -19
- package/dist/client/index.mjs +137 -19
- package/dist/server/index.d.mts +141 -4
- package/dist/server/index.d.ts +141 -4
- package/dist/server/index.js +193 -24
- package/dist/server/index.mjs +198 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,6 +130,164 @@ The loader IIFE is published to a public R2 bucket on every release and
|
|
|
130
130
|
cached for 1y at `loader-vX.Y.Z.js` (immutable) plus a rolling 5-minute
|
|
131
131
|
`loader.js`.
|
|
132
132
|
|
|
133
|
+
## Testing
|
|
134
|
+
|
|
135
|
+
For unit tests, build a **no-network** client with `forTesting()` and seed every
|
|
136
|
+
entity with local overrides (Statsig-style). In test mode the client is already
|
|
137
|
+
"initialized"/"ready", `init()`/`initOnce()`/`identify()` are no-ops (they never
|
|
138
|
+
fetch), `track()` is a no-op, telemetry is disabled, and no SDK key is required —
|
|
139
|
+
so your tests never touch the network.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
// Server (Node / Cloudflare Worker / Deno)
|
|
143
|
+
import { FlagsClient } from "@shipeasy/sdk/server";
|
|
144
|
+
|
|
145
|
+
const client = FlagsClient.forTesting();
|
|
146
|
+
|
|
147
|
+
client.overrideFlag("new_checkout", true);
|
|
148
|
+
client.overrideConfig("upload_limits", { max_uploads: 50 });
|
|
149
|
+
client.overrideExperiment("hero_cta", "treatment", { primary_label: "Buy now" });
|
|
150
|
+
|
|
151
|
+
client.getFlag("new_checkout", { user_id: "u1" }); // true
|
|
152
|
+
client.getConfig("upload_limits"); // { max_uploads: 50 }
|
|
153
|
+
client.getExperiment("hero_cta", { user_id: "u1" }, { primary_label: "Sign up" });
|
|
154
|
+
// → { inExperiment: true, group: "treatment", params: { primary_label: "Buy now" } }
|
|
155
|
+
|
|
156
|
+
client.track("u1", "purchase"); // no-op — never hits the network
|
|
157
|
+
client.clearOverrides(); // reset every override back to default
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// Browser (vanilla JS — no React required)
|
|
162
|
+
import { FlagsClientBrowser } from "@shipeasy/sdk/client";
|
|
163
|
+
|
|
164
|
+
const client = FlagsClientBrowser.forTesting();
|
|
165
|
+
|
|
166
|
+
client.overrideFlag("new_checkout", true);
|
|
167
|
+
client.overrideConfig("upload_limits", { max_uploads: 50 });
|
|
168
|
+
client.overrideExperiment("hero_cta", "treatment", { primary_label: "Buy now" });
|
|
169
|
+
|
|
170
|
+
client.getFlag("new_checkout"); // true
|
|
171
|
+
client.getConfig("upload_limits"); // { max_uploads: 50 }
|
|
172
|
+
client.getExperiment("hero_cta", { primary_label: "Sign up" });
|
|
173
|
+
// → { inExperiment: true, group: "treatment", params: { primary_label: "Buy now" } }
|
|
174
|
+
|
|
175
|
+
client.track("purchase"); // no-op
|
|
176
|
+
client.clearOverrides();
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The `override*` setters also work on a **normal** client (not just `forTesting()`),
|
|
180
|
+
mirroring Statsig — a programmatic override always wins over the fetched values.
|
|
181
|
+
In the browser the precedence is: programmatic override > URL/devtools override
|
|
182
|
+
(`?se_gate_…` / `?se_config_…` / `?se_exp_…`) > the server's evaluation.
|
|
183
|
+
|
|
184
|
+
**API:**
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
overrideFlag(name: string, value: boolean): void;
|
|
188
|
+
overrideConfig(name: string, value: unknown): void;
|
|
189
|
+
overrideExperiment(name: string, group: string, params: Record<string, unknown>): void;
|
|
190
|
+
clearOverrides(): void;
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Default values
|
|
194
|
+
|
|
195
|
+
`getFlag` / `getConfig` take a caller-supplied default that is returned **only
|
|
196
|
+
when the value can't be evaluated** — the client isn't initialized yet, or the
|
|
197
|
+
key isn't in the loaded rules. A flag that legitimately evaluates to `false`
|
|
198
|
+
(disabled, rule denied, rolled out to 0%) still returns `false`, never the
|
|
199
|
+
default.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// Server
|
|
203
|
+
flags.get("new_checkout", { user_id: "u1" }); // false for a missing flag
|
|
204
|
+
flags.get("new_checkout", { user_id: "u1" }, true); // true only if not-ready/not-found
|
|
205
|
+
flags.getConfig("limits", { defaultValue: { max: 50 } }); // default when key absent
|
|
206
|
+
|
|
207
|
+
// Browser (no user arg)
|
|
208
|
+
client.getFlag("new_checkout"); // false for a missing flag
|
|
209
|
+
client.getFlag("new_checkout", true); // default when not-ready/not-found
|
|
210
|
+
client.getConfig("limits", { defaultValue: { max: 50 } });
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The legacy `getConfig(name, decode)` callback signature still works unchanged;
|
|
214
|
+
the options object (`{ decode?, defaultValue? }`) is the new, additive form.
|
|
215
|
+
|
|
216
|
+
## Evaluation detail
|
|
217
|
+
|
|
218
|
+
`getFlagDetail(name[, user])` returns `{ value, reason }` (LaunchDarkly
|
|
219
|
+
`variationDetail` parity) so you can see *why* a flag resolved the way it did:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import type { FlagReason } from "@shipeasy/sdk/server"; // or /client
|
|
223
|
+
|
|
224
|
+
const d = client.getFlagDetail("new_checkout", { user_id: "u1" });
|
|
225
|
+
// → { value: true, reason: "RULE_MATCH" }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`FlagReason` is one of:
|
|
229
|
+
|
|
230
|
+
| reason | meaning |
|
|
231
|
+
| ------------------ | ---------------------------------------------------------- |
|
|
232
|
+
| `CLIENT_NOT_READY` | no rules loaded yet (`init()` / `identify()` pending) |
|
|
233
|
+
| `FLAG_NOT_FOUND` | the gate name isn't in the loaded rules |
|
|
234
|
+
| `OFF` | the gate exists but is disabled / killed (server only) |
|
|
235
|
+
| `OVERRIDE` | a local override (or `?se_gate_…` URL override) decided it |
|
|
236
|
+
| `RULE_MATCH` | the gate evaluated `true` |
|
|
237
|
+
| `DEFAULT` | the gate evaluated `false` |
|
|
238
|
+
|
|
239
|
+
`getFlag` is implemented on top of `getFlagDetail` (single evaluation, single
|
|
240
|
+
telemetry beacon). On the browser the server pre-evaluates the gate's
|
|
241
|
+
enabled/killed state into a boolean, so `OFF` folds into `DEFAULT` there.
|
|
242
|
+
|
|
243
|
+
## Change listeners
|
|
244
|
+
|
|
245
|
+
The server `FlagsClient` fires registered listeners after a **background poll
|
|
246
|
+
returns new data** (HTTP 200, not 304) — handy for invalidating a cache or
|
|
247
|
+
re-rendering when a flag flips. Returns an unsubscribe function. Never fires in
|
|
248
|
+
test/offline mode (no polling happens):
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const client = new FlagsClient({ apiKey: process.env.SHIPEASY_SERVER_KEY! });
|
|
252
|
+
await client.init();
|
|
253
|
+
const unsubscribe = client.onChange(() => {
|
|
254
|
+
console.log("flag rules changed — re-evaluating");
|
|
255
|
+
});
|
|
256
|
+
// later…
|
|
257
|
+
unsubscribe();
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The browser `FlagsClientBrowser` already exposes the equivalent `subscribe()`
|
|
261
|
+
(fires after each `identify()` / override change).
|
|
262
|
+
|
|
263
|
+
## Offline snapshot
|
|
264
|
+
|
|
265
|
+
Build a fully offline server client from a captured snapshot — zero network.
|
|
266
|
+
Evaluations run the real eval against the snapshot; `init()`/`initOnce()`/
|
|
267
|
+
`track()` are no-ops and overrides still apply on top. The snapshot is just the
|
|
268
|
+
two SDK wire bodies:
|
|
269
|
+
|
|
270
|
+
```jsonc
|
|
271
|
+
// snapshot.json
|
|
272
|
+
{
|
|
273
|
+
"flags": /* body of GET /sdk/flags */,
|
|
274
|
+
"experiments": /* body of GET /sdk/experiments */
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { FlagsClient } from "@shipeasy/sdk/server";
|
|
280
|
+
|
|
281
|
+
const client = FlagsClient.fromFile("./snapshot.json");
|
|
282
|
+
// or, if you already hold the parsed object:
|
|
283
|
+
const client = FlagsClient.fromSnapshot({ flags, experiments });
|
|
284
|
+
|
|
285
|
+
client.getFlag("new_checkout", { user_id: "u1" });
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`fromFile` is Node-only (it reads the file with `node:fs`); `fromSnapshot` works
|
|
289
|
+
anywhere.
|
|
290
|
+
|
|
133
291
|
## Devtools overlay
|
|
134
292
|
|
|
135
293
|
Press `Shift+Alt+S` on any page running the SDK (or append `?se=1` to the
|
package/dist/client/index.d.mts
CHANGED
|
@@ -120,6 +120,33 @@ interface ExperimentResult<P> {
|
|
|
120
120
|
group: string;
|
|
121
121
|
params: P;
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
|
|
125
|
+
* Computed at the client boundary:
|
|
126
|
+
* - CLIENT_NOT_READY — no eval result yet (identify() hasn't resolved)
|
|
127
|
+
* - FLAG_NOT_FOUND — the gate name isn't present in the eval result
|
|
128
|
+
* - OFF — folded into DEFAULT here (the server pre-evaluates the
|
|
129
|
+
* gate's enabled/killed state into a plain boolean, so the browser can't
|
|
130
|
+
* distinguish a disabled gate from one a rule denied)
|
|
131
|
+
* - OVERRIDE — a local override or a ?se_gate_/?se_ks_ URL override
|
|
132
|
+
* decided the value
|
|
133
|
+
* - RULE_MATCH — the gate evaluated true
|
|
134
|
+
* - DEFAULT — the gate evaluated false
|
|
135
|
+
*/
|
|
136
|
+
declare const FLAG_REASONS: readonly ["CLIENT_NOT_READY", "FLAG_NOT_FOUND", "OFF", "OVERRIDE", "RULE_MATCH", "DEFAULT"];
|
|
137
|
+
type FlagReason = (typeof FLAG_REASONS)[number];
|
|
138
|
+
interface FlagDetail {
|
|
139
|
+
value: boolean;
|
|
140
|
+
reason: FlagReason;
|
|
141
|
+
}
|
|
142
|
+
/** Options object form of `getConfig` — keeps the legacy `decode` callback and
|
|
143
|
+
* adds a `defaultValue` returned when the config key is absent. */
|
|
144
|
+
interface GetConfigOptions<T = unknown> {
|
|
145
|
+
/** Decode the raw stored value into the typed shape callers want. */
|
|
146
|
+
decode?: (raw: unknown) => T;
|
|
147
|
+
/** Returned when the config key is absent (not overridden, not in the eval result). */
|
|
148
|
+
defaultValue?: T;
|
|
149
|
+
}
|
|
123
150
|
interface EvalExpResult {
|
|
124
151
|
inExperiment: boolean;
|
|
125
152
|
group: string;
|
|
@@ -180,6 +207,13 @@ interface FlagsClientBrowserOptions {
|
|
|
180
207
|
disableTelemetry?: boolean;
|
|
181
208
|
/** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
|
|
182
209
|
telemetryUrl?: string;
|
|
210
|
+
/**
|
|
211
|
+
* Test mode — no network at all. identify()/init are no-ops (never call
|
|
212
|
+
* /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
|
|
213
|
+
* starts "ready" with an empty eval result. Prefer the
|
|
214
|
+
* {@link FlagsClientBrowser.forTesting} factory over passing this directly.
|
|
215
|
+
*/
|
|
216
|
+
testMode?: boolean;
|
|
183
217
|
}
|
|
184
218
|
declare class FlagsClientBrowser {
|
|
185
219
|
private readonly sdkKey;
|
|
@@ -198,8 +232,26 @@ declare class FlagsClientBrowser {
|
|
|
198
232
|
private listeners;
|
|
199
233
|
private overrideListenerInstalled;
|
|
200
234
|
private identifySeq;
|
|
235
|
+
private readonly testMode;
|
|
236
|
+
private readonly flagOverrides;
|
|
237
|
+
private readonly configOverrides;
|
|
238
|
+
private readonly experimentOverrides;
|
|
201
239
|
private onOverrideChange;
|
|
202
240
|
constructor(opts: FlagsClientBrowserOptions);
|
|
241
|
+
/**
|
|
242
|
+
* Build a no-network, immediately-usable browser client for tests
|
|
243
|
+
* (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
|
|
244
|
+
* is a no-op, telemetry is disabled, and the client is already ready — seed
|
|
245
|
+
* every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
|
|
246
|
+
* key required.
|
|
247
|
+
*
|
|
248
|
+
* ```ts
|
|
249
|
+
* const client = FlagsClientBrowser.forTesting();
|
|
250
|
+
* client.overrideFlag("new_checkout", true);
|
|
251
|
+
* client.getFlag("new_checkout"); // true
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
static forTesting(opts?: Partial<FlagsClientBrowserOptions>): FlagsClientBrowser;
|
|
203
255
|
identify(user: User): Promise<void>;
|
|
204
256
|
/**
|
|
205
257
|
* Report a structured error into the errors primitive. Flushes immediately
|
|
@@ -210,8 +262,35 @@ declare class FlagsClientBrowser {
|
|
|
210
262
|
get ready(): boolean;
|
|
211
263
|
private notify;
|
|
212
264
|
initFromBootstrap(data: EvalResponse): void;
|
|
213
|
-
getFlag(name
|
|
265
|
+
/** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
|
|
266
|
+
overrideFlag(name: string, value: boolean): void;
|
|
267
|
+
/** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
|
|
268
|
+
overrideConfig(name: string, value: unknown): void;
|
|
269
|
+
/**
|
|
270
|
+
* Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
|
|
271
|
+
* ignoring URL overrides, the eval result, and exposure logging.
|
|
272
|
+
*/
|
|
273
|
+
overrideExperiment(name: string, group: string, params: Record<string, unknown>): void;
|
|
274
|
+
/** Remove every programmatic override set via the override* methods. */
|
|
275
|
+
clearOverrides(): void;
|
|
276
|
+
/**
|
|
277
|
+
* Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
|
|
278
|
+
* local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
|
|
279
|
+
* telemetry; otherwise exactly one "gate" beacon is emitted. The server
|
|
280
|
+
* pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
|
|
281
|
+
* folds into DEFAULT here (the browser can't tell "disabled" from "rule
|
|
282
|
+
* denied").
|
|
283
|
+
*/
|
|
284
|
+
getFlagDetail(name: string): FlagDetail;
|
|
285
|
+
/**
|
|
286
|
+
* Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
|
|
287
|
+
* evaluated (not ready or flag not found) — never for a gate that legitimately
|
|
288
|
+
* evaluates to false. Plain `getFlag(name)` keeps returning false for a
|
|
289
|
+
* missing flag.
|
|
290
|
+
*/
|
|
291
|
+
getFlag(name: string, defaultValue?: boolean): boolean;
|
|
214
292
|
getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
|
|
293
|
+
getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
|
|
215
294
|
getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
|
|
216
295
|
/**
|
|
217
296
|
* Subscribe to state changes — fires after identify() completes and on
|
|
@@ -388,7 +467,9 @@ declare const flags: {
|
|
|
388
467
|
* Everything else gates on _mountedAndReady to prevent hydration mismatches on
|
|
389
468
|
* force-static pages where SSR has no flag data.
|
|
390
469
|
*/
|
|
391
|
-
get(name: string): boolean;
|
|
470
|
+
get(name: string, defaultValue?: boolean): boolean;
|
|
471
|
+
/** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
|
|
472
|
+
getDetail(name: string): FlagDetail;
|
|
392
473
|
getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
|
|
393
474
|
getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
|
|
394
475
|
track(eventName: string, props?: Record<string, unknown>): void;
|
|
@@ -509,4 +590,4 @@ interface I18nFacade {
|
|
|
509
590
|
}
|
|
510
591
|
declare const i18n: I18nFacade;
|
|
511
592
|
|
|
512
|
-
export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
|
|
593
|
+
export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FLAG_REASONS, type FlagDetail, type FlagReason, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type GetConfigOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
|
package/dist/client/index.d.ts
CHANGED
|
@@ -120,6 +120,33 @@ interface ExperimentResult<P> {
|
|
|
120
120
|
group: string;
|
|
121
121
|
params: P;
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
|
|
125
|
+
* Computed at the client boundary:
|
|
126
|
+
* - CLIENT_NOT_READY — no eval result yet (identify() hasn't resolved)
|
|
127
|
+
* - FLAG_NOT_FOUND — the gate name isn't present in the eval result
|
|
128
|
+
* - OFF — folded into DEFAULT here (the server pre-evaluates the
|
|
129
|
+
* gate's enabled/killed state into a plain boolean, so the browser can't
|
|
130
|
+
* distinguish a disabled gate from one a rule denied)
|
|
131
|
+
* - OVERRIDE — a local override or a ?se_gate_/?se_ks_ URL override
|
|
132
|
+
* decided the value
|
|
133
|
+
* - RULE_MATCH — the gate evaluated true
|
|
134
|
+
* - DEFAULT — the gate evaluated false
|
|
135
|
+
*/
|
|
136
|
+
declare const FLAG_REASONS: readonly ["CLIENT_NOT_READY", "FLAG_NOT_FOUND", "OFF", "OVERRIDE", "RULE_MATCH", "DEFAULT"];
|
|
137
|
+
type FlagReason = (typeof FLAG_REASONS)[number];
|
|
138
|
+
interface FlagDetail {
|
|
139
|
+
value: boolean;
|
|
140
|
+
reason: FlagReason;
|
|
141
|
+
}
|
|
142
|
+
/** Options object form of `getConfig` — keeps the legacy `decode` callback and
|
|
143
|
+
* adds a `defaultValue` returned when the config key is absent. */
|
|
144
|
+
interface GetConfigOptions<T = unknown> {
|
|
145
|
+
/** Decode the raw stored value into the typed shape callers want. */
|
|
146
|
+
decode?: (raw: unknown) => T;
|
|
147
|
+
/** Returned when the config key is absent (not overridden, not in the eval result). */
|
|
148
|
+
defaultValue?: T;
|
|
149
|
+
}
|
|
123
150
|
interface EvalExpResult {
|
|
124
151
|
inExperiment: boolean;
|
|
125
152
|
group: string;
|
|
@@ -180,6 +207,13 @@ interface FlagsClientBrowserOptions {
|
|
|
180
207
|
disableTelemetry?: boolean;
|
|
181
208
|
/** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
|
|
182
209
|
telemetryUrl?: string;
|
|
210
|
+
/**
|
|
211
|
+
* Test mode — no network at all. identify()/init are no-ops (never call
|
|
212
|
+
* /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
|
|
213
|
+
* starts "ready" with an empty eval result. Prefer the
|
|
214
|
+
* {@link FlagsClientBrowser.forTesting} factory over passing this directly.
|
|
215
|
+
*/
|
|
216
|
+
testMode?: boolean;
|
|
183
217
|
}
|
|
184
218
|
declare class FlagsClientBrowser {
|
|
185
219
|
private readonly sdkKey;
|
|
@@ -198,8 +232,26 @@ declare class FlagsClientBrowser {
|
|
|
198
232
|
private listeners;
|
|
199
233
|
private overrideListenerInstalled;
|
|
200
234
|
private identifySeq;
|
|
235
|
+
private readonly testMode;
|
|
236
|
+
private readonly flagOverrides;
|
|
237
|
+
private readonly configOverrides;
|
|
238
|
+
private readonly experimentOverrides;
|
|
201
239
|
private onOverrideChange;
|
|
202
240
|
constructor(opts: FlagsClientBrowserOptions);
|
|
241
|
+
/**
|
|
242
|
+
* Build a no-network, immediately-usable browser client for tests
|
|
243
|
+
* (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
|
|
244
|
+
* is a no-op, telemetry is disabled, and the client is already ready — seed
|
|
245
|
+
* every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
|
|
246
|
+
* key required.
|
|
247
|
+
*
|
|
248
|
+
* ```ts
|
|
249
|
+
* const client = FlagsClientBrowser.forTesting();
|
|
250
|
+
* client.overrideFlag("new_checkout", true);
|
|
251
|
+
* client.getFlag("new_checkout"); // true
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
static forTesting(opts?: Partial<FlagsClientBrowserOptions>): FlagsClientBrowser;
|
|
203
255
|
identify(user: User): Promise<void>;
|
|
204
256
|
/**
|
|
205
257
|
* Report a structured error into the errors primitive. Flushes immediately
|
|
@@ -210,8 +262,35 @@ declare class FlagsClientBrowser {
|
|
|
210
262
|
get ready(): boolean;
|
|
211
263
|
private notify;
|
|
212
264
|
initFromBootstrap(data: EvalResponse): void;
|
|
213
|
-
getFlag(name
|
|
265
|
+
/** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
|
|
266
|
+
overrideFlag(name: string, value: boolean): void;
|
|
267
|
+
/** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
|
|
268
|
+
overrideConfig(name: string, value: unknown): void;
|
|
269
|
+
/**
|
|
270
|
+
* Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
|
|
271
|
+
* ignoring URL overrides, the eval result, and exposure logging.
|
|
272
|
+
*/
|
|
273
|
+
overrideExperiment(name: string, group: string, params: Record<string, unknown>): void;
|
|
274
|
+
/** Remove every programmatic override set via the override* methods. */
|
|
275
|
+
clearOverrides(): void;
|
|
276
|
+
/**
|
|
277
|
+
* Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
|
|
278
|
+
* local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
|
|
279
|
+
* telemetry; otherwise exactly one "gate" beacon is emitted. The server
|
|
280
|
+
* pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
|
|
281
|
+
* folds into DEFAULT here (the browser can't tell "disabled" from "rule
|
|
282
|
+
* denied").
|
|
283
|
+
*/
|
|
284
|
+
getFlagDetail(name: string): FlagDetail;
|
|
285
|
+
/**
|
|
286
|
+
* Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
|
|
287
|
+
* evaluated (not ready or flag not found) — never for a gate that legitimately
|
|
288
|
+
* evaluates to false. Plain `getFlag(name)` keeps returning false for a
|
|
289
|
+
* missing flag.
|
|
290
|
+
*/
|
|
291
|
+
getFlag(name: string, defaultValue?: boolean): boolean;
|
|
214
292
|
getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
|
|
293
|
+
getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
|
|
215
294
|
getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
|
|
216
295
|
/**
|
|
217
296
|
* Subscribe to state changes — fires after identify() completes and on
|
|
@@ -388,7 +467,9 @@ declare const flags: {
|
|
|
388
467
|
* Everything else gates on _mountedAndReady to prevent hydration mismatches on
|
|
389
468
|
* force-static pages where SSR has no flag data.
|
|
390
469
|
*/
|
|
391
|
-
get(name: string): boolean;
|
|
470
|
+
get(name: string, defaultValue?: boolean): boolean;
|
|
471
|
+
/** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
|
|
472
|
+
getDetail(name: string): FlagDetail;
|
|
392
473
|
getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
|
|
393
474
|
getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
|
|
394
475
|
track(eventName: string, props?: Record<string, unknown>): void;
|
|
@@ -509,4 +590,4 @@ interface I18nFacade {
|
|
|
509
590
|
}
|
|
510
591
|
declare const i18n: I18nFacade;
|
|
511
592
|
|
|
512
|
-
export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
|
|
593
|
+
export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FLAG_REASONS, type FlagDetail, type FlagReason, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type GetConfigOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
|
package/dist/client/index.js
CHANGED
|
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/client/index.ts
|
|
21
21
|
var client_exports = {};
|
|
22
22
|
__export(client_exports, {
|
|
23
|
+
FLAG_REASONS: () => FLAG_REASONS,
|
|
23
24
|
FlagsClientBrowser: () => FlagsClientBrowser,
|
|
24
25
|
LABEL_MARKER_END: () => LABEL_MARKER_END,
|
|
25
26
|
LABEL_MARKER_RE: () => LABEL_MARKER_RE,
|
|
@@ -358,6 +359,14 @@ var SeeLimiter = class {
|
|
|
358
359
|
|
|
359
360
|
// src/client/index.ts
|
|
360
361
|
var version = "4.0.0";
|
|
362
|
+
var FLAG_REASONS = [
|
|
363
|
+
"CLIENT_NOT_READY",
|
|
364
|
+
"FLAG_NOT_FOUND",
|
|
365
|
+
"OFF",
|
|
366
|
+
"OVERRIDE",
|
|
367
|
+
"RULE_MATCH",
|
|
368
|
+
"DEFAULT"
|
|
369
|
+
];
|
|
361
370
|
var FLUSH_INTERVAL_MS = 5e3;
|
|
362
371
|
var MAX_BUFFER = 100;
|
|
363
372
|
var ANON_ID_KEY = "__se_anon_id";
|
|
@@ -869,7 +878,7 @@ function readExperimentOverridesFromUrl() {
|
|
|
869
878
|
}
|
|
870
879
|
return out;
|
|
871
880
|
}
|
|
872
|
-
var FlagsClientBrowser = class {
|
|
881
|
+
var FlagsClientBrowser = class _FlagsClientBrowser {
|
|
873
882
|
sdkKey;
|
|
874
883
|
baseUrl;
|
|
875
884
|
autoGuardrails;
|
|
@@ -888,6 +897,16 @@ var FlagsClientBrowser = class {
|
|
|
888
897
|
// Monotonic counter so a later identify() always wins even if its /sdk/evaluate
|
|
889
898
|
// response races and lands before an earlier in-flight call's response.
|
|
890
899
|
identifySeq = 0;
|
|
900
|
+
// Test mode: built by `FlagsClientBrowser.forTesting()`. When set, identify()
|
|
901
|
+
// never fetches, track() is a no-op, telemetry is off, and the client is
|
|
902
|
+
// already ready with an empty eval result.
|
|
903
|
+
testMode;
|
|
904
|
+
// Programmatic overrides (Statsig-style). Set on any client via
|
|
905
|
+
// overrideFlag/overrideConfig/overrideExperiment; they win over BOTH the URL
|
|
906
|
+
// overrides and the fetched eval result. Cleared by clearOverrides().
|
|
907
|
+
flagOverrides = /* @__PURE__ */ new Map();
|
|
908
|
+
configOverrides = /* @__PURE__ */ new Map();
|
|
909
|
+
experimentOverrides = /* @__PURE__ */ new Map();
|
|
891
910
|
onOverrideChange = () => {
|
|
892
911
|
this.installBridge();
|
|
893
912
|
this.notify();
|
|
@@ -896,6 +915,7 @@ var FlagsClientBrowser = class {
|
|
|
896
915
|
this.sdkKey = opts.sdkKey;
|
|
897
916
|
this.baseUrl = (opts.baseUrl ?? "https://edge.shipeasy.dev").replace(/\/$/, "");
|
|
898
917
|
this.env = opts.env ?? "prod";
|
|
918
|
+
this.testMode = opts.testMode === true;
|
|
899
919
|
this.autoGuardrails = opts.autoGuardrails !== false;
|
|
900
920
|
this.autoCollectAlways = opts.autoCollectAlways === true;
|
|
901
921
|
const g = opts.autoGuardrailGroups ?? {};
|
|
@@ -911,11 +931,42 @@ var FlagsClientBrowser = class {
|
|
|
911
931
|
sdkKey: this.sdkKey,
|
|
912
932
|
side: "client",
|
|
913
933
|
env: this.env,
|
|
914
|
-
|
|
934
|
+
// Test mode never talks to the network — telemetry off regardless of opt.
|
|
935
|
+
disabled: this.testMode || opts.disableTelemetry
|
|
936
|
+
});
|
|
937
|
+
if (this.testMode) {
|
|
938
|
+
this.evalResult = { flags: {}, configs: {}, experiments: {}, killswitches: {} };
|
|
939
|
+
} else {
|
|
940
|
+
void this.buffer.flushPendingAlias();
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Build a no-network, immediately-usable browser client for tests
|
|
945
|
+
* (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
|
|
946
|
+
* is a no-op, telemetry is disabled, and the client is already ready — seed
|
|
947
|
+
* every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
|
|
948
|
+
* key required.
|
|
949
|
+
*
|
|
950
|
+
* ```ts
|
|
951
|
+
* const client = FlagsClientBrowser.forTesting();
|
|
952
|
+
* client.overrideFlag("new_checkout", true);
|
|
953
|
+
* client.getFlag("new_checkout"); // true
|
|
954
|
+
* ```
|
|
955
|
+
*/
|
|
956
|
+
static forTesting(opts) {
|
|
957
|
+
return new _FlagsClientBrowser({
|
|
958
|
+
sdkKey: "",
|
|
959
|
+
autoGuardrails: false,
|
|
960
|
+
...opts,
|
|
961
|
+
testMode: true
|
|
915
962
|
});
|
|
916
|
-
void this.buffer.flushPendingAlias();
|
|
917
963
|
}
|
|
918
964
|
async identify(user) {
|
|
965
|
+
if (this.testMode) {
|
|
966
|
+
if (user.user_id !== void 0) this.userId = user.user_id;
|
|
967
|
+
this.notify();
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
919
970
|
const seq = ++this.identifySeq;
|
|
920
971
|
const prevUserId = this.userId;
|
|
921
972
|
if (user.user_id !== void 0) this.userId = user.user_id;
|
|
@@ -990,22 +1041,75 @@ var FlagsClientBrowser = class {
|
|
|
990
1041
|
initFromBootstrap(data) {
|
|
991
1042
|
this.evalResult = data;
|
|
992
1043
|
}
|
|
993
|
-
|
|
1044
|
+
// ---- Local overrides (Statsig-style) ----
|
|
1045
|
+
//
|
|
1046
|
+
// Precedence (highest first): programmatic override (these methods) > URL
|
|
1047
|
+
// override (?se_gate_/?se_config_/?se_exp_) > fetched eval result. A
|
|
1048
|
+
// programmatic override is an explicit in-code decision, so it wins over the
|
|
1049
|
+
// ad-hoc URL/devtools overrides as well as the server's evaluation.
|
|
1050
|
+
/** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
|
|
1051
|
+
overrideFlag(name, value) {
|
|
1052
|
+
this.flagOverrides.set(name, value);
|
|
1053
|
+
}
|
|
1054
|
+
/** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
|
|
1055
|
+
overrideConfig(name, value) {
|
|
1056
|
+
this.configOverrides.set(name, value);
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
|
|
1060
|
+
* ignoring URL overrides, the eval result, and exposure logging.
|
|
1061
|
+
*/
|
|
1062
|
+
overrideExperiment(name, group, params) {
|
|
1063
|
+
this.experimentOverrides.set(name, { group, params });
|
|
1064
|
+
}
|
|
1065
|
+
/** Remove every programmatic override set via the override* methods. */
|
|
1066
|
+
clearOverrides() {
|
|
1067
|
+
this.flagOverrides.clear();
|
|
1068
|
+
this.configOverrides.clear();
|
|
1069
|
+
this.experimentOverrides.clear();
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
|
|
1073
|
+
* local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
|
|
1074
|
+
* telemetry; otherwise exactly one "gate" beacon is emitted. The server
|
|
1075
|
+
* pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
|
|
1076
|
+
* folds into DEFAULT here (the browser can't tell "disabled" from "rule
|
|
1077
|
+
* denied").
|
|
1078
|
+
*/
|
|
1079
|
+
getFlagDetail(name) {
|
|
1080
|
+
const pov = this.flagOverrides.get(name);
|
|
1081
|
+
if (pov !== void 0) return { value: pov, reason: "OVERRIDE" };
|
|
1082
|
+
const urlOv = readGateOverride(name);
|
|
1083
|
+
if (urlOv !== null) return { value: urlOv, reason: "OVERRIDE" };
|
|
994
1084
|
this.telemetry.emit("gate", name);
|
|
995
|
-
if (this.evalResult === null) return false;
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
return
|
|
1085
|
+
if (this.evalResult === null) return { value: false, reason: "CLIENT_NOT_READY" };
|
|
1086
|
+
if (!(name in this.evalResult.flags)) return { value: false, reason: "FLAG_NOT_FOUND" };
|
|
1087
|
+
const value = this.evalResult.flags[name] ?? false;
|
|
1088
|
+
return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
|
|
999
1089
|
}
|
|
1000
|
-
|
|
1090
|
+
/**
|
|
1091
|
+
* Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
|
|
1092
|
+
* evaluated (not ready or flag not found) — never for a gate that legitimately
|
|
1093
|
+
* evaluates to false. Plain `getFlag(name)` keeps returning false for a
|
|
1094
|
+
* missing flag.
|
|
1095
|
+
*/
|
|
1096
|
+
getFlag(name, defaultValue = false) {
|
|
1097
|
+
const d = this.getFlagDetail(name);
|
|
1098
|
+
if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
|
|
1099
|
+
return d.value;
|
|
1100
|
+
}
|
|
1101
|
+
getConfig(name, decodeOrOpts) {
|
|
1001
1102
|
this.telemetry.emit("config", name);
|
|
1002
|
-
|
|
1003
|
-
const
|
|
1004
|
-
const
|
|
1005
|
-
if (
|
|
1006
|
-
|
|
1103
|
+
const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
|
|
1104
|
+
const fallback = "defaultValue" in opts ? opts.defaultValue : void 0;
|
|
1105
|
+
const hasProgrammatic = this.configOverrides.has(name);
|
|
1106
|
+
if (!hasProgrammatic && this.evalResult === null) return fallback;
|
|
1107
|
+
const urlOv = hasProgrammatic ? void 0 : readConfigOverride(name);
|
|
1108
|
+
const raw = hasProgrammatic ? this.configOverrides.get(name) : urlOv !== void 0 ? urlOv : this.evalResult?.configs?.[name];
|
|
1109
|
+
if (raw === void 0) return fallback;
|
|
1110
|
+
if (!opts.decode) return raw;
|
|
1007
1111
|
try {
|
|
1008
|
-
return decode(raw);
|
|
1112
|
+
return opts.decode(raw);
|
|
1009
1113
|
} catch (err) {
|
|
1010
1114
|
console.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
|
|
1011
1115
|
return void 0;
|
|
@@ -1018,6 +1122,12 @@ var FlagsClientBrowser = class {
|
|
|
1018
1122
|
group: "control",
|
|
1019
1123
|
params: defaultParams
|
|
1020
1124
|
};
|
|
1125
|
+
const pov = this.experimentOverrides.get(name);
|
|
1126
|
+
if (pov) {
|
|
1127
|
+
const variantParams = variants?.[pov.group];
|
|
1128
|
+
const params = { ...defaultParams, ...pov.params, ...variantParams ?? {} };
|
|
1129
|
+
return { inExperiment: true, group: pov.group, params };
|
|
1130
|
+
}
|
|
1021
1131
|
const ov = readExpOverride(name);
|
|
1022
1132
|
if (ov !== null) {
|
|
1023
1133
|
const variantParams = variants?.[ov];
|
|
@@ -1069,6 +1179,7 @@ var FlagsClientBrowser = class {
|
|
|
1069
1179
|
return bridge;
|
|
1070
1180
|
}
|
|
1071
1181
|
track(eventName, props) {
|
|
1182
|
+
if (this.testMode) return;
|
|
1072
1183
|
this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
|
|
1073
1184
|
}
|
|
1074
1185
|
/**
|
|
@@ -1287,12 +1398,19 @@ var flags = {
|
|
|
1287
1398
|
* Everything else gates on _mountedAndReady to prevent hydration mismatches on
|
|
1288
1399
|
* force-static pages where SSR has no flag data.
|
|
1289
1400
|
*/
|
|
1290
|
-
get(name) {
|
|
1401
|
+
get(name, defaultValue = false) {
|
|
1291
1402
|
const bs = getBootstrap();
|
|
1292
1403
|
if (bs !== null && name in bs.flags) return bs.flags[name];
|
|
1293
|
-
if (!_mountedAndReady) return
|
|
1294
|
-
if (_client) return _client.getFlag(name);
|
|
1295
|
-
return readGateOverride(name) ??
|
|
1404
|
+
if (!_mountedAndReady) return defaultValue;
|
|
1405
|
+
if (_client) return _client.getFlag(name, defaultValue);
|
|
1406
|
+
return readGateOverride(name) ?? defaultValue;
|
|
1407
|
+
},
|
|
1408
|
+
/** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
|
|
1409
|
+
getDetail(name) {
|
|
1410
|
+
if (_client) return _client.getFlagDetail(name);
|
|
1411
|
+
const ov = readGateOverride(name);
|
|
1412
|
+
if (ov !== null) return { value: ov, reason: "OVERRIDE" };
|
|
1413
|
+
return { value: false, reason: "CLIENT_NOT_READY" };
|
|
1296
1414
|
},
|
|
1297
1415
|
getConfig(name, decode) {
|
|
1298
1416
|
const bs = getBootstrap();
|
|
@@ -1624,6 +1742,7 @@ var i18n = {
|
|
|
1624
1742
|
};
|
|
1625
1743
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1626
1744
|
0 && (module.exports = {
|
|
1745
|
+
FLAG_REASONS,
|
|
1627
1746
|
FlagsClientBrowser,
|
|
1628
1747
|
LABEL_MARKER_END,
|
|
1629
1748
|
LABEL_MARKER_RE,
|