@variantlab/core 0.1.4 → 0.1.6
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 +1209 -39
- package/docs/API.md +692 -0
- package/docs/ARCHITECTURE.md +430 -0
- package/docs/CONTRIBUTING.md +264 -0
- package/docs/ROADMAP.md +292 -0
- package/docs/SECURITY.md +323 -0
- package/docs/design/api-philosophy.md +347 -0
- package/docs/design/config-format.md +442 -0
- package/docs/design/design-principles.md +212 -0
- package/docs/design/targeting-dsl.md +433 -0
- package/docs/features/codegen.md +351 -0
- package/docs/features/crash-rollback.md +399 -0
- package/docs/features/debug-overlay.md +328 -0
- package/docs/features/hmac-signing.md +330 -0
- package/docs/features/killer-features.md +308 -0
- package/docs/features/multivariate.md +339 -0
- package/docs/features/qr-sharing.md +372 -0
- package/docs/features/targeting.md +481 -0
- package/docs/features/time-travel.md +306 -0
- package/docs/features/value-experiments.md +487 -0
- package/docs/phases/phase-2-expansion.md +307 -0
- package/docs/phases/phase-3-ecosystem.md +289 -0
- package/docs/phases/phase-4-advanced.md +306 -0
- package/docs/phases/phase-5-v1-stable.md +350 -0
- package/docs/research/bundle-size-analysis.md +279 -0
- package/docs/research/competitors.md +327 -0
- package/docs/research/framework-ssr-quirks.md +394 -0
- package/docs/research/naming-rationale.md +238 -0
- package/docs/research/origin-story.md +179 -0
- package/docs/research/security-threats.md +312 -0
- package/package.json +2 -1
package/docs/API.md
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
Complete TypeScript API surface for all packages. This is the source of truth during Phase 0 — any PR that changes an API must update this file first.
|
|
4
|
+
|
|
5
|
+
## Table of contents
|
|
6
|
+
|
|
7
|
+
- [@variantlab/core](#variantlabcore)
|
|
8
|
+
- [Types](#types)
|
|
9
|
+
- [Engine](#engine)
|
|
10
|
+
- [Storage interface](#storage-interface)
|
|
11
|
+
- [Fetcher interface](#fetcher-interface)
|
|
12
|
+
- [Telemetry interface](#telemetry-interface)
|
|
13
|
+
- [Targeting](#targeting)
|
|
14
|
+
- [Assignment strategies](#assignment-strategies)
|
|
15
|
+
- [Errors](#errors)
|
|
16
|
+
- [@variantlab/react](#variantlabreact)
|
|
17
|
+
- [@variantlab/react-native](#variantlabreact-native)
|
|
18
|
+
- [@variantlab/next](#variantlabnext)
|
|
19
|
+
- [@variantlab/cli](#variantlabcli)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## `@variantlab/core`
|
|
24
|
+
|
|
25
|
+
### Types
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
/** Top-level config file shape. */
|
|
29
|
+
export interface ExperimentsConfig {
|
|
30
|
+
/** Schema version. Current is 1. */
|
|
31
|
+
version: 1;
|
|
32
|
+
/** Optional HMAC signature for remote configs. Verified via Web Crypto. */
|
|
33
|
+
signature?: string;
|
|
34
|
+
/** Optional global kill switch. When false, all experiments return defaults. */
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
/** The experiments. */
|
|
37
|
+
experiments: Experiment[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A single experiment definition. */
|
|
41
|
+
export interface Experiment {
|
|
42
|
+
/** Unique identifier. Must match /^[a-z0-9-]+$/. */
|
|
43
|
+
id: string;
|
|
44
|
+
/** Human-readable name for the debug overlay. */
|
|
45
|
+
name: string;
|
|
46
|
+
/** Human-readable description. Shown in debug overlay. */
|
|
47
|
+
description?: string;
|
|
48
|
+
/** "render" swaps components; "value" returns primitive values. */
|
|
49
|
+
type?: "render" | "value";
|
|
50
|
+
/** The variants to test. Must have at least 2. */
|
|
51
|
+
variants: Variant[];
|
|
52
|
+
/** Default variant ID. Must match one of variants[].id. */
|
|
53
|
+
default: string;
|
|
54
|
+
/** Routes this experiment applies to. Glob patterns. */
|
|
55
|
+
routes?: string[];
|
|
56
|
+
/** Targeting predicates. ALL must match for a user to be eligible. */
|
|
57
|
+
targeting?: Targeting;
|
|
58
|
+
/** Assignment strategy. Defaults to "default". */
|
|
59
|
+
assignment?: AssignmentStrategy;
|
|
60
|
+
/** Traffic split (for "weighted" strategy). */
|
|
61
|
+
split?: Record<string, number>;
|
|
62
|
+
/** Mutual exclusion group. Experiments in the same group cannot co-run. */
|
|
63
|
+
mutex?: string;
|
|
64
|
+
/** Enable crash-triggered rollback for this experiment. */
|
|
65
|
+
rollback?: RollbackConfig;
|
|
66
|
+
/** Lifecycle state. Archived experiments are hidden from debug overlay. */
|
|
67
|
+
status?: "draft" | "active" | "archived";
|
|
68
|
+
/** Start date (ISO 8601). Experiment inactive before this. */
|
|
69
|
+
startDate?: string;
|
|
70
|
+
/** End date (ISO 8601). Experiment inactive after this. */
|
|
71
|
+
endDate?: string;
|
|
72
|
+
/** Owner, for tracking. Free text. */
|
|
73
|
+
owner?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A variant of an experiment. */
|
|
77
|
+
export interface Variant {
|
|
78
|
+
/** Unique within the experiment. Must match /^[a-z0-9-]+$/. */
|
|
79
|
+
id: string;
|
|
80
|
+
/** Human-readable label for debug overlay. */
|
|
81
|
+
label?: string;
|
|
82
|
+
/** Human-readable description. */
|
|
83
|
+
description?: string;
|
|
84
|
+
/** For "value" experiments, the value returned by useVariantValue. */
|
|
85
|
+
value?: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Runtime context used for targeting and assignment. */
|
|
89
|
+
export interface VariantContext {
|
|
90
|
+
/** Stable user identifier for sticky assignment. */
|
|
91
|
+
userId?: string;
|
|
92
|
+
/** Current route / pathname. */
|
|
93
|
+
route?: string;
|
|
94
|
+
/** Platform: "ios" | "android" | "web" | "node". */
|
|
95
|
+
platform?: string;
|
|
96
|
+
/** Semver string. */
|
|
97
|
+
appVersion?: string;
|
|
98
|
+
/** IETF language tag. */
|
|
99
|
+
locale?: string;
|
|
100
|
+
/** Screen size bucket, derived automatically by adapters. */
|
|
101
|
+
screenSize?: "small" | "medium" | "large";
|
|
102
|
+
/** Arbitrary user attributes for custom targeting. */
|
|
103
|
+
attributes?: Record<string, string | number | boolean>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Targeting predicate. All fields are optional; all specified fields must match. */
|
|
107
|
+
export interface Targeting {
|
|
108
|
+
platform?: Array<"ios" | "android" | "web" | "node">;
|
|
109
|
+
appVersion?: string; // semver range, e.g. ">=1.2.0 <2.0.0"
|
|
110
|
+
locale?: string[];
|
|
111
|
+
screenSize?: Array<"small" | "medium" | "large">;
|
|
112
|
+
routes?: string[]; // glob patterns
|
|
113
|
+
userId?: string[] | { hash: string; mod: number }; // explicit list or hash bucket
|
|
114
|
+
attributes?: Record<string, unknown>; // exact match
|
|
115
|
+
predicate?: (context: VariantContext) => boolean; // escape hatch
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Assignment strategy. */
|
|
119
|
+
export type AssignmentStrategy =
|
|
120
|
+
| "default" // always return the default variant
|
|
121
|
+
| "random" // uniform random on first eligibility
|
|
122
|
+
| "sticky-hash" // deterministic hash of (userId, experimentId)
|
|
123
|
+
| "weighted"; // weighted by split config, sticky-hashed
|
|
124
|
+
|
|
125
|
+
/** Crash-rollback configuration. */
|
|
126
|
+
export interface RollbackConfig {
|
|
127
|
+
/** Number of crashes that trigger rollback. Default: 3. */
|
|
128
|
+
threshold: number;
|
|
129
|
+
/** Time window in milliseconds. Default: 60000. */
|
|
130
|
+
window: number;
|
|
131
|
+
/** Whether to persist the rollback across sessions. Default: false. */
|
|
132
|
+
persistent?: boolean;
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Engine
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
/** Options passed to createEngine. */
|
|
140
|
+
export interface EngineOptions {
|
|
141
|
+
/** Storage adapter. Required. */
|
|
142
|
+
storage: Storage;
|
|
143
|
+
/** Optional remote config fetcher. */
|
|
144
|
+
fetcher?: Fetcher;
|
|
145
|
+
/** Optional telemetry sink. */
|
|
146
|
+
telemetry?: Telemetry;
|
|
147
|
+
/** Optional HMAC verification key (raw bytes or CryptoKey). */
|
|
148
|
+
hmacKey?: Uint8Array | CryptoKey;
|
|
149
|
+
/** Initial runtime context. */
|
|
150
|
+
context?: VariantContext;
|
|
151
|
+
/** Fail-open (return defaults) or fail-closed (throw) on errors. Default: fail-open. */
|
|
152
|
+
errorMode?: "fail-open" | "fail-closed";
|
|
153
|
+
/** Enable time-travel recording. Default: false. */
|
|
154
|
+
timeTravel?: boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The runtime engine. */
|
|
158
|
+
export class VariantEngine {
|
|
159
|
+
constructor(config: ExperimentsConfig, options: EngineOptions);
|
|
160
|
+
|
|
161
|
+
/** Get the current variant ID for an experiment. Synchronous, O(1) after warmup. */
|
|
162
|
+
getVariant(experimentId: string, context?: VariantContext): string;
|
|
163
|
+
|
|
164
|
+
/** Get the variant value (for "value" experiments). */
|
|
165
|
+
getVariantValue<T = unknown>(experimentId: string, context?: VariantContext): T;
|
|
166
|
+
|
|
167
|
+
/** Force a variant. Used by debug overlay and deep links. */
|
|
168
|
+
setVariant(experimentId: string, variantId: string): void;
|
|
169
|
+
|
|
170
|
+
/** Clear a forced variant, falling back to assignment. */
|
|
171
|
+
clearVariant(experimentId: string): void;
|
|
172
|
+
|
|
173
|
+
/** Clear all forced variants. */
|
|
174
|
+
resetAll(): void;
|
|
175
|
+
|
|
176
|
+
/** Get all experiments, optionally filtered by route. */
|
|
177
|
+
getExperiments(route?: string): Experiment[];
|
|
178
|
+
|
|
179
|
+
/** Subscribe to variant changes. Used by framework adapters. */
|
|
180
|
+
subscribe(listener: (event: EngineEvent) => void): () => void;
|
|
181
|
+
|
|
182
|
+
/** Update runtime context. Triggers re-evaluation of all experiments. */
|
|
183
|
+
updateContext(patch: Partial<VariantContext>): void;
|
|
184
|
+
|
|
185
|
+
/** Replace the config (e.g., after a remote fetch). Validates + verifies signature. */
|
|
186
|
+
loadConfig(config: ExperimentsConfig): Promise<void>;
|
|
187
|
+
|
|
188
|
+
/** Report a crash for rollback tracking. */
|
|
189
|
+
reportCrash(experimentId: string, error: Error): void;
|
|
190
|
+
|
|
191
|
+
/** Track an arbitrary event. Forwarded to telemetry. */
|
|
192
|
+
track(eventName: string, properties?: Record<string, unknown>): void;
|
|
193
|
+
|
|
194
|
+
/** Get time-travel history (if enabled). */
|
|
195
|
+
getHistory(): EngineEvent[];
|
|
196
|
+
|
|
197
|
+
/** Clean up subscriptions, timers, and listeners. */
|
|
198
|
+
dispose(): void;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Factory — preferred over `new VariantEngine()`. */
|
|
202
|
+
export function createEngine(
|
|
203
|
+
config: ExperimentsConfig,
|
|
204
|
+
options: EngineOptions
|
|
205
|
+
): VariantEngine;
|
|
206
|
+
|
|
207
|
+
/** Events emitted by the engine. */
|
|
208
|
+
export type EngineEvent =
|
|
209
|
+
| { type: "ready"; config: ExperimentsConfig }
|
|
210
|
+
| { type: "assignment"; experimentId: string; variantId: string; context: VariantContext }
|
|
211
|
+
| { type: "exposure"; experimentId: string; variantId: string }
|
|
212
|
+
| { type: "variantChanged"; experimentId: string; variantId: string; source: "user" | "system" | "deeplink" | "qr" }
|
|
213
|
+
| { type: "rollback"; experimentId: string; variantId: string; reason: string }
|
|
214
|
+
| { type: "configLoaded"; config: ExperimentsConfig }
|
|
215
|
+
| { type: "error"; error: Error };
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Storage interface
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
/** Pluggable storage adapter. All methods may be sync or async. */
|
|
222
|
+
export interface Storage {
|
|
223
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
224
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
225
|
+
removeItem(key: string): void | Promise<void>;
|
|
226
|
+
/** Optional: returns all keys with the variantlab prefix. */
|
|
227
|
+
keys?(): string[] | Promise<string[]>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** In-memory storage, useful for tests and SSR. */
|
|
231
|
+
export function createMemoryStorage(): Storage;
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Adapter packages provide concrete implementations:
|
|
235
|
+
|
|
236
|
+
- `@variantlab/react-native` exports `AsyncStorageAdapter`, `MMKVStorageAdapter`, `SecureStoreAdapter`
|
|
237
|
+
- `@variantlab/react` exports `LocalStorageAdapter`, `SessionStorageAdapter`, `CookieStorageAdapter`
|
|
238
|
+
- `@variantlab/next` exports `NextCookieAdapter` (SSR-aware)
|
|
239
|
+
|
|
240
|
+
### Fetcher interface
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
/** Optional remote config fetcher. */
|
|
244
|
+
export interface Fetcher {
|
|
245
|
+
/** Fetch the latest config. May throw on network failure. */
|
|
246
|
+
fetch(): Promise<ExperimentsConfig>;
|
|
247
|
+
/** Polling interval in ms. Default: 0 (no polling). */
|
|
248
|
+
pollInterval?: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Simple HTTP fetcher helper. */
|
|
252
|
+
export function createHttpFetcher(options: {
|
|
253
|
+
url: string;
|
|
254
|
+
headers?: Record<string, string>;
|
|
255
|
+
pollInterval?: number;
|
|
256
|
+
/** Signal for abort. */
|
|
257
|
+
signal?: AbortSignal;
|
|
258
|
+
}): Fetcher;
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Telemetry interface
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
/** Optional telemetry sink. Called for every engine event. */
|
|
265
|
+
export interface Telemetry {
|
|
266
|
+
track(event: EngineEvent): void;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Helper to combine multiple telemetry sinks. */
|
|
270
|
+
export function combineTelemetry(...sinks: Telemetry[]): Telemetry;
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Targeting
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
/**
|
|
277
|
+
* Runtime context augmented with the optional precomputed sha256
|
|
278
|
+
* bucket (0..99) for the hash-mod userId operator. The engine fills
|
|
279
|
+
* this via `hashUserId` on every context update; `evaluate` reads it
|
|
280
|
+
* synchronously.
|
|
281
|
+
*/
|
|
282
|
+
export interface EvalContext extends VariantContext {
|
|
283
|
+
readonly userIdBucket?: number;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Targeting augmented with the optional application-provided predicate
|
|
288
|
+
* escape hatch. Functions can't live in JSON configs, so this type
|
|
289
|
+
* only exists at the evaluation layer.
|
|
290
|
+
*/
|
|
291
|
+
export interface EvaluableTargeting extends Targeting {
|
|
292
|
+
readonly predicate?: (context: VariantContext) => boolean;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Result of a single `evaluate()` call. */
|
|
296
|
+
export interface TargetingResult {
|
|
297
|
+
readonly matched: boolean;
|
|
298
|
+
/** The first failing field name; undefined on match. */
|
|
299
|
+
readonly reason?: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Which field an `ExplainStep` corresponds to. */
|
|
303
|
+
export type ExplainField =
|
|
304
|
+
| "startDate"
|
|
305
|
+
| "endDate"
|
|
306
|
+
| "platform"
|
|
307
|
+
| "screenSize"
|
|
308
|
+
| "locale"
|
|
309
|
+
| "appVersion"
|
|
310
|
+
| "routes"
|
|
311
|
+
| "attributes"
|
|
312
|
+
| "userId"
|
|
313
|
+
| "predicate";
|
|
314
|
+
|
|
315
|
+
/** One step in an `explain()` trace. */
|
|
316
|
+
export interface ExplainStep {
|
|
317
|
+
readonly field: ExplainField;
|
|
318
|
+
readonly matched: boolean;
|
|
319
|
+
/** Short human-readable summary; present on failures. */
|
|
320
|
+
readonly detail?: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Result of `explain()` — a full trace of every check performed. */
|
|
324
|
+
export interface ExplainResult {
|
|
325
|
+
readonly matched: boolean;
|
|
326
|
+
readonly reason?: ExplainField;
|
|
327
|
+
readonly steps: readonly ExplainStep[];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Evaluate a targeting predicate against a context. Pure, synchronous,
|
|
332
|
+
* short-circuits at the first failing field. `evaluate` and `explain`
|
|
333
|
+
* are both sync — use `hashUserId` to precompute the `userIdBucket`
|
|
334
|
+
* field on the context when using hash-bucket targeting.
|
|
335
|
+
*/
|
|
336
|
+
export function evaluate(
|
|
337
|
+
targeting: EvaluableTargeting,
|
|
338
|
+
context: VariantContext | EvalContext,
|
|
339
|
+
): TargetingResult;
|
|
340
|
+
|
|
341
|
+
/** Thin wrapper returning `evaluate(...).matched`. */
|
|
342
|
+
export function matchTargeting(
|
|
343
|
+
targeting: EvaluableTargeting,
|
|
344
|
+
context: VariantContext | EvalContext,
|
|
345
|
+
): boolean;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Walk an experiment's date gates and targeting and return a full
|
|
349
|
+
* trace. Short-circuits at the first failing step; `steps` contains
|
|
350
|
+
* every check actually performed.
|
|
351
|
+
*/
|
|
352
|
+
export function explain(
|
|
353
|
+
experiment: Experiment,
|
|
354
|
+
context: VariantContext | EvalContext,
|
|
355
|
+
): ExplainResult;
|
|
356
|
+
|
|
357
|
+
/** Match a route pattern. Supports `/foo`, `/foo/*`, `/foo/**`, `/user/:id`. */
|
|
358
|
+
export function matchRoute(pattern: string, route: string): boolean;
|
|
359
|
+
|
|
360
|
+
/** Match a semver range. Supports `>=1.2.0`, `^1.2.0`, `1.2.0 - 2.0.0`. */
|
|
361
|
+
export function matchSemver(range: string, version: string): boolean;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Compute a sha256 bucket (0..99) for a userId. Async because Web
|
|
365
|
+
* Crypto is async; the engine precomputes this once per context
|
|
366
|
+
* update and stores the result on `EvalContext.userIdBucket`.
|
|
367
|
+
*/
|
|
368
|
+
export function hashUserId(userId: string): Promise<number>;
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Assignment strategies
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
/** Deterministic hash of (userId + experimentId) to a [0, 1) float. */
|
|
375
|
+
export function stickyHash(userId: string, experimentId: string): number;
|
|
376
|
+
|
|
377
|
+
/** Evaluate an assignment strategy. */
|
|
378
|
+
export function assignVariant(
|
|
379
|
+
experiment: Experiment,
|
|
380
|
+
context: VariantContext
|
|
381
|
+
): string;
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Errors
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
/** A single validation issue surfaced by `validateConfig`. */
|
|
388
|
+
export interface ConfigIssue {
|
|
389
|
+
/** RFC 6901 JSON Pointer into the source config. "" for root. */
|
|
390
|
+
readonly path: string;
|
|
391
|
+
/** Machine-readable code for programmatic handling (CLI, debug overlay). */
|
|
392
|
+
readonly code: IssueCode;
|
|
393
|
+
/** Human-readable message safe for CLI output. */
|
|
394
|
+
readonly message: string;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Union of all validation issue codes.
|
|
398
|
+
* Narrow union in the implementation — see `packages/core/src/config/codes.ts`. */
|
|
399
|
+
export type IssueCode = string;
|
|
400
|
+
|
|
401
|
+
/** Thrown when config validation fails. Collects all issues before throwing. */
|
|
402
|
+
export class ConfigValidationError extends Error {
|
|
403
|
+
readonly issues: ReadonlyArray<ConfigIssue>;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Thrown when HMAC verification fails. */
|
|
407
|
+
export class SignatureVerificationError extends Error {}
|
|
408
|
+
|
|
409
|
+
/** Thrown when an experiment ID is unknown. Only in fail-closed mode. */
|
|
410
|
+
export class UnknownExperimentError extends Error {
|
|
411
|
+
readonly experimentId: string;
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## `@variantlab/react`
|
|
418
|
+
|
|
419
|
+
### Provider
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
export interface VariantLabProviderProps {
|
|
423
|
+
/** The experiments config. Inline JSON or from a fetcher. */
|
|
424
|
+
config: ExperimentsConfig;
|
|
425
|
+
/** Engine options. */
|
|
426
|
+
options?: Omit<EngineOptions, "storage"> & { storage?: Storage };
|
|
427
|
+
/** Runtime context. Defaults to auto-detect (platform, locale, screen size). */
|
|
428
|
+
context?: Partial<VariantContext>;
|
|
429
|
+
/** Children. */
|
|
430
|
+
children: React.ReactNode;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export const VariantLabProvider: React.FC<VariantLabProviderProps>;
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Hooks
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
/** Returns the current variant ID for an experiment. */
|
|
440
|
+
export function useVariant<K extends keyof GeneratedExperiments = string>(
|
|
441
|
+
experimentId: K
|
|
442
|
+
): GeneratedExperiments[K]["variants"] | string;
|
|
443
|
+
|
|
444
|
+
/** Returns the variant value (for "value" experiments). */
|
|
445
|
+
export function useVariantValue<T = unknown>(
|
|
446
|
+
experimentId: string
|
|
447
|
+
): T;
|
|
448
|
+
|
|
449
|
+
/** Returns an object with the variant, value, and event trackers. */
|
|
450
|
+
export function useExperiment<T = unknown>(
|
|
451
|
+
experimentId: string
|
|
452
|
+
): {
|
|
453
|
+
variant: string;
|
|
454
|
+
value: T;
|
|
455
|
+
track: (eventName: string, properties?: Record<string, unknown>) => void;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/** Imperative variant setter. Dev-only by default. */
|
|
459
|
+
export function useSetVariant(): (
|
|
460
|
+
experimentId: string,
|
|
461
|
+
variantId: string
|
|
462
|
+
) => void;
|
|
463
|
+
|
|
464
|
+
/** Low-level engine access. */
|
|
465
|
+
export function useVariantLabEngine(): VariantEngine;
|
|
466
|
+
|
|
467
|
+
/** Returns the list of experiments applicable to the current route. */
|
|
468
|
+
export function useRouteExperiments(route?: string): Experiment[];
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Components
|
|
472
|
+
|
|
473
|
+
```tsx
|
|
474
|
+
/** Render-prop switch for "render" experiments. */
|
|
475
|
+
export interface VariantProps {
|
|
476
|
+
experimentId: string;
|
|
477
|
+
children: Record<string, React.ReactNode>;
|
|
478
|
+
fallback?: React.ReactNode;
|
|
479
|
+
}
|
|
480
|
+
export const Variant: React.FC<VariantProps>;
|
|
481
|
+
|
|
482
|
+
/** Render-prop for "value" experiments. */
|
|
483
|
+
export interface VariantValueProps<T> {
|
|
484
|
+
experimentId: string;
|
|
485
|
+
children: (value: T) => React.ReactNode;
|
|
486
|
+
}
|
|
487
|
+
export function VariantValue<T>(props: VariantValueProps<T>): React.ReactElement;
|
|
488
|
+
|
|
489
|
+
/** Error boundary that reports crashes to the engine. */
|
|
490
|
+
export interface VariantErrorBoundaryProps {
|
|
491
|
+
experimentId: string;
|
|
492
|
+
fallback?: React.ReactNode | ((error: Error) => React.ReactNode);
|
|
493
|
+
children: React.ReactNode;
|
|
494
|
+
}
|
|
495
|
+
export const VariantErrorBoundary: React.ComponentType<VariantErrorBoundaryProps>;
|
|
496
|
+
|
|
497
|
+
/** Debug overlay. Tree-shaken in production. */
|
|
498
|
+
export const VariantDebugOverlay: React.FC<{
|
|
499
|
+
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
500
|
+
routeFilter?: boolean;
|
|
501
|
+
}>;
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## `@variantlab/react-native`
|
|
507
|
+
|
|
508
|
+
Re-exports everything from `@variantlab/react` plus:
|
|
509
|
+
|
|
510
|
+
### Storage adapters
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
/** AsyncStorage-backed storage. Requires @react-native-async-storage/async-storage. */
|
|
514
|
+
export function createAsyncStorageAdapter(): Storage;
|
|
515
|
+
|
|
516
|
+
/** MMKV-backed storage. Requires react-native-mmkv. */
|
|
517
|
+
export function createMMKVStorageAdapter(): Storage;
|
|
518
|
+
|
|
519
|
+
/** Expo SecureStore-backed storage. Requires expo-secure-store. */
|
|
520
|
+
export function createSecureStoreAdapter(): Storage;
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### Debug overlay (RN-specific)
|
|
524
|
+
|
|
525
|
+
```tsx
|
|
526
|
+
/** Native debug overlay with floating button, bottom sheet, QR share, shake-to-open. */
|
|
527
|
+
export const VariantDebugOverlay: React.FC<{
|
|
528
|
+
/** Enable shake-to-open gesture. Default: true. */
|
|
529
|
+
shakeToOpen?: boolean;
|
|
530
|
+
/** Show only experiments matching the current route. Default: true. */
|
|
531
|
+
routeFilter?: boolean;
|
|
532
|
+
/** Button position. */
|
|
533
|
+
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
534
|
+
/** Hide button entirely; open only via shake or programmatic trigger. */
|
|
535
|
+
hideButton?: boolean;
|
|
536
|
+
}>;
|
|
537
|
+
|
|
538
|
+
/** Imperatively open the debug overlay. */
|
|
539
|
+
export function openDebugOverlay(): void;
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### Deep link handler
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
/** Registers a listener for variantlab:// deep links. Returns unsubscribe. */
|
|
546
|
+
export function registerDeepLinkHandler(
|
|
547
|
+
engine: VariantEngine,
|
|
548
|
+
options?: { scheme?: string; host?: string }
|
|
549
|
+
): () => void;
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Auto-detected context
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
/** Returns a VariantContext filled with platform, screenSize, locale. */
|
|
556
|
+
export function getAutoContext(): VariantContext;
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
---
|
|
560
|
+
|
|
561
|
+
## `@variantlab/next`
|
|
562
|
+
|
|
563
|
+
### Server
|
|
564
|
+
|
|
565
|
+
```ts
|
|
566
|
+
/** Create an engine for server-side use. Singleton per request or per process. */
|
|
567
|
+
export function createVariantLabServer(
|
|
568
|
+
config: ExperimentsConfig,
|
|
569
|
+
options?: Omit<EngineOptions, "storage"> & { storage?: Storage }
|
|
570
|
+
): VariantEngine;
|
|
571
|
+
|
|
572
|
+
/** Read a variant from a Next.js request (App Router or Pages Router). */
|
|
573
|
+
export function getVariantSSR(
|
|
574
|
+
experimentId: string,
|
|
575
|
+
request: Request | NextApiRequest,
|
|
576
|
+
config: ExperimentsConfig
|
|
577
|
+
): string;
|
|
578
|
+
|
|
579
|
+
/** Read a variant value from a Next.js request. */
|
|
580
|
+
export function getVariantValueSSR<T = unknown>(
|
|
581
|
+
experimentId: string,
|
|
582
|
+
request: Request | NextApiRequest,
|
|
583
|
+
config: ExperimentsConfig
|
|
584
|
+
): T;
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### Middleware
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
/** Next.js middleware that assigns a sticky cookie for variant stability. */
|
|
591
|
+
export function variantLabMiddleware(config: ExperimentsConfig): (
|
|
592
|
+
req: NextRequest
|
|
593
|
+
) => NextResponse;
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### Client (re-exports from `@variantlab/react`)
|
|
597
|
+
|
|
598
|
+
```tsx
|
|
599
|
+
export {
|
|
600
|
+
VariantLabProvider,
|
|
601
|
+
useVariant,
|
|
602
|
+
useVariantValue,
|
|
603
|
+
useExperiment,
|
|
604
|
+
Variant,
|
|
605
|
+
VariantValue,
|
|
606
|
+
VariantErrorBoundary,
|
|
607
|
+
VariantDebugOverlay,
|
|
608
|
+
} from "@variantlab/react";
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### App Router usage
|
|
612
|
+
|
|
613
|
+
```tsx
|
|
614
|
+
// app/layout.tsx
|
|
615
|
+
import { cookies } from "next/headers";
|
|
616
|
+
import { VariantLabProvider } from "@variantlab/next/client";
|
|
617
|
+
import experiments from "./experiments.json";
|
|
618
|
+
|
|
619
|
+
export default function RootLayout({ children }) {
|
|
620
|
+
const initialVariants = /* read from cookies */;
|
|
621
|
+
return (
|
|
622
|
+
<VariantLabProvider config={experiments} initialVariants={initialVariants}>
|
|
623
|
+
{children}
|
|
624
|
+
</VariantLabProvider>
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// middleware.ts
|
|
629
|
+
import { variantLabMiddleware } from "@variantlab/next/middleware";
|
|
630
|
+
import experiments from "./experiments.json";
|
|
631
|
+
|
|
632
|
+
export default variantLabMiddleware(experiments);
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
637
|
+
## `@variantlab/cli`
|
|
638
|
+
|
|
639
|
+
### Commands
|
|
640
|
+
|
|
641
|
+
```
|
|
642
|
+
variantlab init Scaffold experiments.json + install adapter
|
|
643
|
+
variantlab generate [--out <path>] Generate .d.ts from experiments.json
|
|
644
|
+
variantlab validate [--config <path>] Validate config + check for orphaned IDs
|
|
645
|
+
variantlab scaffold <experimentId> Scaffold boilerplate for a new experiment
|
|
646
|
+
variantlab sign --key <path> HMAC-sign an experiments.json
|
|
647
|
+
variantlab verify --key <path> Verify an HMAC-signed experiments.json
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
### `variantlab generate`
|
|
651
|
+
|
|
652
|
+
Reads `experiments.json` (or path from `--config`) and writes a `.d.ts` with:
|
|
653
|
+
|
|
654
|
+
- A `GeneratedExperiments` interface mapping IDs to literal union types for variant IDs
|
|
655
|
+
- Module augmentation that narrows `useVariant(id)` returns
|
|
656
|
+
- JSDoc with experiment names and descriptions for IDE tooltips
|
|
657
|
+
|
|
658
|
+
Example output:
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
// variantlab-generated.d.ts (DO NOT EDIT)
|
|
662
|
+
|
|
663
|
+
declare module "@variantlab/core" {
|
|
664
|
+
interface GeneratedExperiments {
|
|
665
|
+
/** CTA button copy */
|
|
666
|
+
"cta-copy": {
|
|
667
|
+
variants: "buy-now" | "get-started" | "try-free";
|
|
668
|
+
value: string;
|
|
669
|
+
};
|
|
670
|
+
/** News card layout */
|
|
671
|
+
"news-card-layout": {
|
|
672
|
+
variants: "responsive" | "scale-to-fit" | "pip-thumbnail";
|
|
673
|
+
value: never;
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export {};
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
---
|
|
682
|
+
|
|
683
|
+
## API stability
|
|
684
|
+
|
|
685
|
+
Everything above is **proposed, not implemented**. During Phase 0 any signature in this file may change in response to design review. The goal is to lock it before Phase 1 begins.
|
|
686
|
+
|
|
687
|
+
PRs that change public APIs must:
|
|
688
|
+
|
|
689
|
+
1. Update this file first
|
|
690
|
+
2. Include a changeset describing the change
|
|
691
|
+
3. Link to a GitHub discussion for non-trivial changes
|
|
692
|
+
4. Provide a migration guide if breaking
|