@variantlab/core 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/README.md +1214 -75
  2. package/package.json +10 -11
  3. package/LICENSE +0 -21
package/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  > The framework-agnostic A/B testing and feature-flag engine. Zero runtime dependencies, runs anywhere.
4
4
 
5
- ![npm version](https://img.shields.io/npm/v/@variantlab/core/alpha?label=npm&color=blue)
5
+ ![npm version](https://img.shields.io/npm/v/@variantlab/core?label=npm&color=blue)
6
6
  ![bundle size](https://img.shields.io/badge/gzip-%3C3KB-brightgreen)
7
7
  ![dependencies](https://img.shields.io/badge/runtime%20deps-0-brightgreen)
8
8
 
9
9
  ## Install
10
10
 
11
11
  ```bash
12
- npm install @variantlab/core@alpha
12
+ npm install @variantlab/core
13
13
  ```
14
14
 
15
15
  ## Quick start
@@ -24,6 +24,7 @@ Create an `experiments.json` file:
24
24
  "experiments": [
25
25
  {
26
26
  "id": "cta-copy",
27
+ "name": "CTA button copy",
27
28
  "type": "value",
28
29
  "default": "buy-now",
29
30
  "variants": [
@@ -33,6 +34,7 @@ Create an `experiments.json` file:
33
34
  },
34
35
  {
35
36
  "id": "hero-layout",
37
+ "name": "Hero layout",
36
38
  "type": "render",
37
39
  "default": "centered",
38
40
  "variants": [
@@ -120,43 +122,6 @@ const trace = explain(experiments, "hero-layout", {
120
122
  // Returns step-by-step targeting trace with pass/fail per field
121
123
  ```
122
124
 
123
- ## Assignment strategies
124
-
125
- The engine supports multiple assignment strategies per experiment:
126
-
127
- | Strategy | Description |
128
- |----------|-------------|
129
- | `default` | Always assigns the default variant |
130
- | `random` | Random assignment on each evaluation |
131
- | `sticky-hash` | Deterministic hash-based assignment (requires `userId`) |
132
- | `weighted` | Weighted random distribution |
133
-
134
- ```json
135
- {
136
- "id": "pricing",
137
- "type": "value",
138
- "default": "low",
139
- "assignment": { "strategy": "sticky-hash" },
140
- "variants": [
141
- { "id": "low", "value": 9.99, "weight": 50 },
142
- { "id": "high", "value": 14.99, "weight": 50 }
143
- ]
144
- }
145
- ```
146
-
147
- ## Targeting operators
148
-
149
- Built-in targeting predicates:
150
-
151
- - `platform` — `ios`, `android`, `web`
152
- - `appVersion` — semver ranges (`>=1.2.0`, `^2.0.0`)
153
- - `locale` — locale codes (`en`, `bn`, `fr`)
154
- - `screenSize` — `small`, `medium`, `large`
155
- - `routes` — glob patterns (`/settings/*`, `/dashboard`)
156
- - `userId` — exact match or list
157
- - `attributes` — custom key-value matching
158
- - `predicate` — compound `and`/`or`/`not` logic
159
-
160
125
  ## Key features
161
126
 
162
127
  - Zero runtime dependencies
@@ -181,53 +146,1227 @@ Use `@variantlab/core` directly for vanilla JS/TS, or pair it with a framework a
181
146
 
182
147
  ---
183
148
 
184
- ## Documentation
149
+ # Config format (`experiments.json`)
150
+
151
+ The canonical specification for the `experiments.json` file.
152
+
153
+ ## File structure
154
+
155
+ ```json
156
+ {
157
+ "$schema": "https://variantlab.dev/schemas/experiments.schema.json",
158
+ "version": 1,
159
+ "signature": "base64url-hmac-optional",
160
+ "enabled": true,
161
+ "experiments": [
162
+ {
163
+ "id": "example",
164
+ "name": "Example experiment",
165
+ "variants": [
166
+ { "id": "a" },
167
+ { "id": "b" }
168
+ ],
169
+ "default": "a"
170
+ }
171
+ ]
172
+ }
173
+ ```
174
+
175
+ ## Top-level fields
176
+
177
+ | Field | Type | Required | Description |
178
+ |---|---|:-:|---|
179
+ | `$schema` | string | No | JSON Schema reference for IDE support. Ignored by the engine. |
180
+ | `version` | integer | Yes | Schema version. Must be `1`. The engine rejects unknown versions. |
181
+ | `signature` | string | No | Base64url-encoded HMAC-SHA256 of the canonical form of `experiments`. Verified via Web Crypto API when an `hmacKey` is configured. |
182
+ | `enabled` | boolean | No | Global kill switch. When `false`, all experiments return their default variant. Defaults to `true`. |
183
+ | `experiments` | array | Yes | Array of experiment definitions. Max 1000 entries. |
184
+
185
+ ## Experiment fields
186
+
187
+ | Field | Type | Required | Default | Description |
188
+ |---|---|:-:|---|---|
189
+ | `id` | string | Yes | — | Unique identifier. `/^[a-z0-9][a-z0-9-]{0,63}$/` |
190
+ | `name` | string | Yes | — | Human-readable. Max 128 chars. |
191
+ | `description` | string | No | — | Shown in debug overlay. Max 512 chars. |
192
+ | `type` | enum | No | `"render"` | `"render"` for component swaps, `"value"` for returned values. |
193
+ | `variants` | array | Yes | — | At least 2, at most 100. |
194
+ | `default` | string | Yes | — | Must match one of `variants[].id`. |
195
+ | `routes` | array | No | — | Glob patterns. Max 100. |
196
+ | `targeting` | object | No | — | Targeting predicate. |
197
+ | `assignment` | enum | No | `"default"` | Strategy: `default | random | sticky-hash | weighted`. |
198
+ | `split` | object | No | — | Traffic split for `weighted` strategy. |
199
+ | `mutex` | string | No | — | Mutual exclusion group. |
200
+ | `rollback` | object | No | — | Crash-rollback configuration. |
201
+ | `status` | enum | No | `"active"` | `draft | active | archived`. |
202
+ | `startDate` | ISO 8601 | No | — | Inactive before this. |
203
+ | `endDate` | ISO 8601 | No | — | Inactive after this. |
204
+ | `owner` | string | No | — | Free text. Max 128 chars. |
205
+ | `overridable` | boolean | No | `false` | Whether deep link overrides are accepted. |
206
+
207
+ ### Experiment `id`
208
+
209
+ Case-sensitive, lowercase. Allowed characters: `a-z`, `0-9`, `-`. Max 64 characters. Must not start with a hyphen.
210
+
211
+ - `cta-copy` — valid
212
+ - `news-card-layout` — valid
213
+ - `checkout-v2` — valid
214
+ - `CTA_copy` — invalid (uppercase + underscore)
215
+ - `-leading-dash` — invalid
216
+
217
+ ### Experiment `type`
218
+
219
+ - `"render"`: designed for `<Variant>` component-swap usage. Variants don't need a `value`.
220
+ - `"value"`: designed for `useVariantValue` usage. Each variant has a `value` field.
221
+
222
+ ### Experiment `default`
223
+
224
+ Required. Must reference a valid variant ID. Used when:
225
+
226
+ - Targeting fails
227
+ - Kill switch is on
228
+ - `startDate` is in the future or `endDate` is in the past
229
+ - Engine is in fail-open mode and an error occurs
230
+ - Deep link override is not allowed
231
+ - Crash rollback has triggered
232
+
233
+ ### Experiment `routes`
234
+
235
+ Glob patterns matching current route/pathname. Supported patterns:
236
+
237
+ - Exact: `/`, `/about`
238
+ - Wildcard segment: `/blog/*`
239
+ - Wildcard deep: `/docs/**`
240
+ - Parameter: `/user/:id`
241
+ - Trailing-slash insensitive
242
+
243
+ ### Assignment strategies
244
+
245
+ - `"default"` — always return the default variant. Useful for pre-launch experiments.
246
+ - `"random"` — uniform random across variants, assigned once per user and cached.
247
+ - `"sticky-hash"` — deterministic hash of `(userId, experimentId)` mapped to a variant. Stable across devices for the same `userId`.
248
+ - `"weighted"` — traffic split via `split` field. Uses sticky-hash for determinism.
249
+
250
+ ### Traffic split
251
+
252
+ Required when `assignment: "weighted"`. Object mapping variant IDs to integer percentages summing to 100.
253
+
254
+ ```json
255
+ {
256
+ "id": "pricing",
257
+ "type": "value",
258
+ "default": "low",
259
+ "assignment": "weighted",
260
+ "split": { "control": 50, "treatment-a": 25, "treatment-b": 25 },
261
+ "variants": [
262
+ { "id": "control", "value": 9.99 },
263
+ { "id": "treatment-a", "value": 12.99 },
264
+ { "id": "treatment-b", "value": 14.99 }
265
+ ]
266
+ }
267
+ ```
268
+
269
+ ### Mutex groups
270
+
271
+ Experiments with the same `mutex` cannot co-run on the same user. When two mutex'd experiments both target a user, the engine picks one by stable hash and excludes the others.
272
+
273
+ ### Experiment `status`
274
+
275
+ - `"draft"` — visible in debug overlay (with a draft badge), returns default in production
276
+ - `"active"` — normal operation
277
+ - `"archived"` — hidden from debug overlay, returns default
278
+
279
+ ### Time gates (`startDate` / `endDate`)
280
+
281
+ ISO 8601 timestamps. Inclusive start, exclusive end. Useful for time-boxed rollouts.
282
+
283
+ ## Variant fields
284
+
285
+ | Field | Type | Required | Description |
286
+ |---|---|:-:|---|
287
+ | `id` | string | Yes | Unique within the experiment. Same regex as experiment ID. |
288
+ | `label` | string | No | Human-readable. Shown in debug overlay. Max 128 chars. |
289
+ | `description` | string | No | Shown in debug overlay. Max 512 chars. |
290
+ | `value` | any | No | For `type: "value"` experiments, the value returned by `getVariantValue`. |
291
+
292
+ ### Variant `value`
293
+
294
+ Any JSON-serializable value. Strings, numbers, booleans, arrays, and objects are all supported. Type safety on the JS/TS side comes via codegen or explicit generic arguments.
295
+
296
+ ## Rollback fields
297
+
298
+ | Field | Type | Required | Default | Description |
299
+ |---|---|:-:|---|---|
300
+ | `threshold` | integer | Yes | `3` | Crashes that trigger rollback. 1-100. |
301
+ | `window` | integer | Yes | `60000` | Time window in ms. 1000-3600000. |
302
+ | `persistent` | boolean | No | `false` | Persist rollback across sessions. |
303
+
304
+ When enabled, if a variant crashes `threshold` times within `window` milliseconds, the engine:
305
+
306
+ 1. Clears the user's assignment for that experiment
307
+ 2. Forces the `default` variant
308
+ 3. Emits an `onRollback` event
309
+ 4. If `persistent`, stores the rollback in Storage
185
310
 
186
- This package ships with full documentation in the `docs/` directory. After installing, find them at `node_modules/@variantlab/core/docs/`.
311
+ ## Validation rules
187
312
 
188
- ### Overview
313
+ The engine validates configs at load time and rejects:
189
314
 
190
- - [ARCHITECTURE.md](./docs/ARCHITECTURE.md) monorepo layout, runtime data flow, size budgets
191
- - [API.md](./docs/API.md) complete TypeScript API surface
192
- - [SECURITY.md](./docs/SECURITY.md) threat model, mitigations, reporting
193
- - [ROADMAP.md](./docs/ROADMAP.md) phased feature rollout
194
- - [CONTRIBUTING.md](./docs/CONTRIBUTING.md) how to contribute
315
+ - Unknown version (`version !== 1`)
316
+ - Config larger than 1 MB
317
+ - Duplicate experiment IDs
318
+ - Duplicate variant IDs within an experiment
319
+ - `default` that doesn't match any variant
320
+ - `split` sum != 100 when assignment is `weighted`
321
+ - Invalid route globs (unsupported patterns)
322
+ - Invalid semver ranges
323
+ - Targeting nesting deeper than 10 levels
324
+ - Invalid ISO 8601 timestamps
325
+ - Reserved keys (`__proto__`, `constructor`, `prototype`)
195
326
 
196
- ### Research
327
+ Errors are collected and thrown as a `ConfigValidationError` with an `issues` array. In fail-open mode (default), the engine logs the error and falls back to returning defaults. In fail-closed mode, it throws.
328
+
329
+ ## Config examples
330
+
331
+ ### Simple value experiment
332
+
333
+ ```json
334
+ {
335
+ "version": 1,
336
+ "experiments": [
337
+ {
338
+ "id": "cta-copy",
339
+ "name": "CTA button copy",
340
+ "type": "value",
341
+ "default": "buy-now",
342
+ "variants": [
343
+ { "id": "buy-now", "value": "Buy now" },
344
+ { "id": "get-started", "value": "Get started" },
345
+ { "id": "try-free", "value": "Try it free" }
346
+ ]
347
+ }
348
+ ]
349
+ }
350
+ ```
197
351
 
198
- - [competitors.md](./docs/research/competitors.md) full competitor analysis
199
- - [bundle-size-analysis.md](./docs/research/bundle-size-analysis.md) — how we hit < 3 KB
200
- - [framework-ssr-quirks.md](./docs/research/framework-ssr-quirks.md) — per-framework SSR notes
201
- - [origin-story.md](./docs/research/origin-story.md) — the small-phone card problem that started it
202
- - [naming-rationale.md](./docs/research/naming-rationale.md) — why "variantlab"
203
- - [security-threats.md](./docs/research/security-threats.md) — threat landscape review
352
+ ### Render experiment with route scope
204
353
 
205
- ### Design decisions
354
+ ```json
355
+ {
356
+ "version": 1,
357
+ "experiments": [
358
+ {
359
+ "id": "news-card-layout",
360
+ "name": "News card layout",
361
+ "routes": ["/", "/feed"],
362
+ "targeting": { "screenSize": ["small"] },
363
+ "default": "responsive",
364
+ "variants": [
365
+ { "id": "responsive", "label": "Responsive image" },
366
+ { "id": "scale-to-fit", "label": "Scale to fit" },
367
+ { "id": "pip-thumbnail", "label": "PIP thumbnail" }
368
+ ]
369
+ }
370
+ ]
371
+ }
372
+ ```
206
373
 
207
- - [design-principles.md](./docs/design/design-principles.md) the 8 core principles with rationale
208
- - [config-format.md](./docs/design/config-format.md) — `experiments.json` specification
209
- - [targeting-dsl.md](./docs/design/targeting-dsl.md) — targeting predicate language
210
- - [api-philosophy.md](./docs/design/api-philosophy.md) — why the API looks the way it does
374
+ ### Weighted rollout with rollback
211
375
 
212
- ### Feature specs
376
+ ```json
377
+ {
378
+ "version": 1,
379
+ "experiments": [
380
+ {
381
+ "id": "new-checkout",
382
+ "name": "New checkout flow",
383
+ "assignment": "weighted",
384
+ "split": { "control": 90, "new": 10 },
385
+ "default": "control",
386
+ "variants": [
387
+ { "id": "control" },
388
+ { "id": "new" }
389
+ ],
390
+ "rollback": {
391
+ "threshold": 5,
392
+ "window": 120000,
393
+ "persistent": true
394
+ }
395
+ }
396
+ ]
397
+ }
398
+ ```
213
399
 
214
- - [codegen.md](./docs/features/codegen.md) — type generation from config
215
- - [targeting.md](./docs/features/targeting.md) — segmentation predicates
216
- - [value-experiments.md](./docs/features/value-experiments.md) — non-render variant values
217
- - [debug-overlay.md](./docs/features/debug-overlay.md) — runtime picker UX
218
- - [crash-rollback.md](./docs/features/crash-rollback.md) — error-boundary-driven auto-rollback
219
- - [qr-sharing.md](./docs/features/qr-sharing.md) — state sharing via QR codes
220
- - [hmac-signing.md](./docs/features/hmac-signing.md) — config integrity verification
221
- - [time-travel.md](./docs/features/time-travel.md) — record + replay debugging
222
- - [multivariate.md](./docs/features/multivariate.md) — crossed experiments
223
- - [killer-features.md](./docs/features/killer-features.md) — the 10 differentiators
400
+ ### Time-boxed experiment
224
401
 
225
- ### Roadmap
402
+ ```json
403
+ {
404
+ "version": 1,
405
+ "experiments": [
406
+ {
407
+ "id": "black-friday-banner",
408
+ "name": "Black Friday banner",
409
+ "type": "render",
410
+ "startDate": "2026-11-24T00:00:00Z",
411
+ "endDate": "2026-12-01T00:00:00Z",
412
+ "default": "hidden",
413
+ "variants": [
414
+ { "id": "hidden" },
415
+ { "id": "shown" }
416
+ ]
417
+ }
418
+ ]
419
+ }
420
+ ```
421
+
422
+ ### Targeted beta
423
+
424
+ ```json
425
+ {
426
+ "version": 1,
427
+ "experiments": [
428
+ {
429
+ "id": "ai-assistant",
430
+ "name": "AI assistant beta",
431
+ "targeting": {
432
+ "platform": ["ios", "android"],
433
+ "appVersion": ">=2.0.0",
434
+ "attributes": { "betaOptIn": true }
435
+ },
436
+ "default": "disabled",
437
+ "variants": [
438
+ { "id": "disabled" },
439
+ { "id": "enabled" }
440
+ ]
441
+ }
442
+ ]
443
+ }
444
+ ```
445
+
446
+ ---
447
+
448
+ # Targeting DSL
449
+
450
+ How targeting predicates work and the semantics of each operator.
451
+
452
+ ## The predicate shape
453
+
454
+ ```ts
455
+ interface Targeting {
456
+ platform?: Array<"ios" | "android" | "web" | "node">;
457
+ appVersion?: string; // semver range
458
+ locale?: string[]; // IETF language tags
459
+ screenSize?: Array<"small" | "medium" | "large">;
460
+ routes?: string[]; // glob patterns
461
+ userId?: string[] | { hash: "sha256"; mod: number };
462
+ attributes?: Record<string, string | number | boolean>;
463
+ predicate?: (context: VariantContext) => boolean; // escape hatch, code-only
464
+ }
465
+ ```
466
+
467
+ A `Targeting` object is an **implicit AND** of all specified fields. If no fields are specified, the predicate matches every user.
468
+
469
+ ## Evaluation semantics
470
+
471
+ ```
472
+ match(targeting, context) =
473
+ platform_match(targeting.platform, context.platform)
474
+ AND appVersion_match(targeting.appVersion, context.appVersion)
475
+ AND locale_match(targeting.locale, context.locale)
476
+ AND screenSize_match(targeting.screenSize, context.screenSize)
477
+ AND routes_match(targeting.routes, context.route)
478
+ AND userId_match(targeting.userId, context.userId)
479
+ AND attributes_match(targeting.attributes, context.attributes)
480
+ AND predicate(context)
481
+ ```
482
+
483
+ Each sub-match is:
484
+
485
+ - **True if the field is not specified in targeting** (open by default)
486
+ - **True if the specified predicate matches**
487
+ - **False otherwise**
488
+
489
+ An unspecified field in the context does **not** match a specified targeting field. For example, if `targeting.platform` is `["ios"]` and `context.platform` is `undefined`, the targeting fails.
490
+
491
+ ## Targeting operators
492
+
493
+ ### `platform`
494
+
495
+ ```ts
496
+ platform?: Array<"ios" | "android" | "web" | "node">;
497
+ ```
498
+
499
+ Set membership. Matches if `context.platform` is in the array.
500
+
501
+ - `"ios"` — iOS, iPadOS
502
+ - `"android"` — Android
503
+ - `"web"` — any browser environment (desktop web, mobile web, PWA)
504
+ - `"node"` — server-side (SSR, edge runtimes)
505
+
506
+ ### `appVersion`
507
+
508
+ ```ts
509
+ appVersion?: string; // semver range
510
+ ```
511
+
512
+ Semver range matching. Supported syntax (subset of npm semver):
513
+
514
+ - Comparators: `=`, `<`, `<=`, `>`, `>=`
515
+ - Caret: `^1.2.0` (>= 1.2.0 < 2.0.0)
516
+ - Tilde: `~1.2.0` (>= 1.2.0 < 1.3.0)
517
+ - Range: `1.2.0 - 2.0.0`
518
+ - Compound: `>=1.0.0 <2.0.0`
519
+ - OR ranges: `>=1.0.0 <2.0.0 || >=3.0.0`
520
+
521
+ ### `locale`
522
+
523
+ ```ts
524
+ locale?: string[]; // IETF language tags
525
+ ```
526
+
527
+ Two match modes:
528
+
529
+ - **Exact**: `"en-US"` matches `"en-US"` only
530
+ - **Prefix**: `"en"` matches `"en"`, `"en-US"`, `"en-GB"`, etc.
531
+
532
+ ### `screenSize`
533
+
534
+ ```ts
535
+ screenSize?: Array<"small" | "medium" | "large">;
536
+ ```
537
+
538
+ Set membership on pre-bucketed screen sizes:
539
+
540
+ - `"small"`: `max(width, height) < 700 px`
541
+ - `"medium"`: `700 <= max(width, height) < 1200 px`
542
+ - `"large"`: `max(width, height) >= 1200 px`
543
+
544
+ Thresholds are configurable at engine creation.
545
+
546
+ ### `routes`
547
+
548
+ ```ts
549
+ routes?: string[]; // glob patterns
550
+ ```
551
+
552
+ Matches if `context.route` matches any pattern. Supported:
553
+
554
+ - Exact: `/about`
555
+ - Wildcard segment: `/blog/*`
556
+ - Wildcard deep: `/docs/**`
557
+ - Parameter: `/user/:id`
558
+ - Trailing slash insensitive
559
+
560
+ ### `userId`
561
+
562
+ ```ts
563
+ userId?: string[] | { hash: "sha256"; mod: number };
564
+ ```
565
+
566
+ Two modes:
567
+
568
+ **Explicit list:**
569
+
570
+ ```json
571
+ "userId": ["alice", "bob", "charlie"]
572
+ ```
573
+
574
+ Matches if `context.userId` is in the list. Max 10,000 entries.
575
+
576
+ **Hash bucket:**
577
+
578
+ ```json
579
+ "userId": { "hash": "sha256", "mod": 10 }
580
+ ```
581
+
582
+ Matches if `sha256(userId) % 100 < mod`. In this example, 10% of users match. Uses Web Crypto API for uniform distribution.
583
+
584
+ ### `attributes`
585
+
586
+ ```ts
587
+ attributes?: Record<string, string | number | boolean>;
588
+ ```
589
+
590
+ Exact-match predicate on `context.attributes`. Every specified key must match exactly.
591
+
592
+ ```json
593
+ "targeting": {
594
+ "attributes": {
595
+ "plan": "premium",
596
+ "region": "us-west",
597
+ "betaOptIn": true
598
+ }
599
+ }
600
+ ```
601
+
602
+ ### The `predicate` escape hatch
603
+
604
+ ```ts
605
+ targeting: {
606
+ predicate: (context) => context.daysSinceInstall > 7 && context.isPremium
607
+ }
608
+ ```
609
+
610
+ The `predicate` field is a function available **only in application code**, never in JSON configs. It is ANDed with the other targeting fields. Use it for complex logic not covered by built-in operators.
611
+
612
+ ## Evaluation order
613
+
614
+ The engine evaluates predicates in this order for fast short-circuiting:
615
+
616
+ 1. `enabled` kill switch (O(1))
617
+ 2. `startDate` / `endDate` (O(1))
618
+ 3. `platform` (O(n), n <= 4)
619
+ 4. `screenSize` (O(n), n <= 3)
620
+ 5. `locale` (O(n))
621
+ 6. `appVersion` (O(n), n = range tokens)
622
+ 7. `routes` (O(n x m), n = patterns, m = path segments)
623
+ 8. `attributes` (O(n))
624
+ 9. `userId` (O(n) for list; O(hash) for bucket)
625
+ 10. `predicate` (O(?) — unknown, runs last)
626
+
627
+ ## Targeting examples
628
+
629
+ ### Target iOS users on small screens running the latest version
630
+
631
+ ```json
632
+ "targeting": {
633
+ "platform": ["ios"],
634
+ "screenSize": ["small"],
635
+ "appVersion": ">=2.0.0"
636
+ }
637
+ ```
638
+
639
+ ### Target 10% of users deterministically
640
+
641
+ ```json
642
+ "targeting": {
643
+ "userId": { "hash": "sha256", "mod": 10 }
644
+ }
645
+ ```
226
646
 
227
- - [phase-2-expansion.md](./docs/phases/phase-2-expansion.md) Remix, Vue, vanilla JS, devtools
228
- - [phase-3-ecosystem.md](./docs/phases/phase-3-ecosystem.md) — Svelte, Solid, Astro, Nuxt, addons
229
- - [phase-4-advanced.md](./docs/phases/phase-4-advanced.md) — HMAC GA, crash rollback GA, time travel
230
- - [phase-5-v1-stable.md](./docs/phases/phase-5-v1-stable.md) — v1.0 release criteria
647
+ ### Target premium users in Bengali locale
648
+
649
+ ```json
650
+ "targeting": {
651
+ "locale": ["bn"],
652
+ "attributes": { "plan": "premium" }
653
+ }
654
+ ```
655
+
656
+ ### Time-based targeting (application code)
657
+
658
+ ```ts
659
+ const targeting = {
660
+ platform: ["ios", "android"],
661
+ predicate: (ctx) => {
662
+ const installDate = new Date(ctx.attributes.installDate as string);
663
+ const daysSinceInstall = (Date.now() - installDate.getTime()) / 86400000;
664
+ return daysSinceInstall >= 7 && daysSinceInstall <= 30;
665
+ }
666
+ };
667
+ ```
668
+
669
+ ---
670
+
671
+ # API Reference
672
+
673
+ Complete TypeScript API surface for all packages.
674
+
675
+ ## Core types
676
+
677
+ ```ts
678
+ /** Top-level config file shape. */
679
+ export interface ExperimentsConfig {
680
+ version: 1;
681
+ signature?: string;
682
+ enabled?: boolean;
683
+ experiments: Experiment[];
684
+ }
685
+
686
+ /** A single experiment definition. */
687
+ export interface Experiment {
688
+ id: string;
689
+ name: string;
690
+ description?: string;
691
+ type?: "render" | "value";
692
+ variants: Variant[];
693
+ default: string;
694
+ routes?: string[];
695
+ targeting?: Targeting;
696
+ assignment?: AssignmentStrategy;
697
+ split?: Record<string, number>;
698
+ mutex?: string;
699
+ rollback?: RollbackConfig;
700
+ status?: "draft" | "active" | "archived";
701
+ startDate?: string;
702
+ endDate?: string;
703
+ owner?: string;
704
+ }
705
+
706
+ /** A variant of an experiment. */
707
+ export interface Variant {
708
+ id: string;
709
+ label?: string;
710
+ description?: string;
711
+ value?: unknown;
712
+ }
713
+
714
+ /** Runtime context used for targeting and assignment. */
715
+ export interface VariantContext {
716
+ userId?: string;
717
+ route?: string;
718
+ platform?: string;
719
+ appVersion?: string;
720
+ locale?: string;
721
+ screenSize?: "small" | "medium" | "large";
722
+ attributes?: Record<string, string | number | boolean>;
723
+ }
724
+
725
+ /** Targeting predicate. All fields are optional; all specified fields must match. */
726
+ export interface Targeting {
727
+ platform?: Array<"ios" | "android" | "web" | "node">;
728
+ appVersion?: string;
729
+ locale?: string[];
730
+ screenSize?: Array<"small" | "medium" | "large">;
731
+ routes?: string[];
732
+ userId?: string[] | { hash: string; mod: number };
733
+ attributes?: Record<string, unknown>;
734
+ predicate?: (context: VariantContext) => boolean;
735
+ }
736
+
737
+ /** Assignment strategy. */
738
+ export type AssignmentStrategy =
739
+ | "default" // always return the default variant
740
+ | "random" // uniform random on first eligibility
741
+ | "sticky-hash" // deterministic hash of (userId, experimentId)
742
+ | "weighted"; // weighted by split config, sticky-hashed
743
+
744
+ /** Crash-rollback configuration. */
745
+ export interface RollbackConfig {
746
+ threshold: number;
747
+ window: number;
748
+ persistent?: boolean;
749
+ }
750
+ ```
751
+
752
+ ## Engine API
753
+
754
+ ```ts
755
+ /** Options passed to createEngine. */
756
+ export interface EngineOptions {
757
+ storage: Storage;
758
+ fetcher?: Fetcher;
759
+ telemetry?: Telemetry;
760
+ hmacKey?: Uint8Array | CryptoKey;
761
+ context?: VariantContext;
762
+ errorMode?: "fail-open" | "fail-closed";
763
+ timeTravel?: boolean;
764
+ }
765
+
766
+ /** The runtime engine. */
767
+ export class VariantEngine {
768
+ constructor(config: ExperimentsConfig, options: EngineOptions);
769
+
770
+ /** Get the current variant ID for an experiment. Synchronous, O(1) after warmup. */
771
+ getVariant(experimentId: string, context?: VariantContext): string;
772
+
773
+ /** Get the variant value (for "value" experiments). */
774
+ getVariantValue<T = unknown>(experimentId: string, context?: VariantContext): T;
775
+
776
+ /** Force a variant. Used by debug overlay and deep links. */
777
+ setVariant(experimentId: string, variantId: string): void;
778
+
779
+ /** Clear a forced variant, falling back to assignment. */
780
+ clearVariant(experimentId: string): void;
781
+
782
+ /** Clear all forced variants. */
783
+ resetAll(): void;
784
+
785
+ /** Get all experiments, optionally filtered by route. */
786
+ getExperiments(route?: string): Experiment[];
787
+
788
+ /** Subscribe to variant changes. Returns unsubscribe function. */
789
+ subscribe(listener: (event: EngineEvent) => void): () => void;
790
+
791
+ /** Update runtime context. Triggers re-evaluation of all experiments. */
792
+ updateContext(patch: Partial<VariantContext>): void;
793
+
794
+ /** Replace the config (e.g., after a remote fetch). Validates + verifies signature. */
795
+ loadConfig(config: ExperimentsConfig): Promise<void>;
796
+
797
+ /** Report a crash for rollback tracking. */
798
+ reportCrash(experimentId: string, error: Error): void;
799
+
800
+ /** Track an arbitrary event. Forwarded to telemetry. */
801
+ track(eventName: string, properties?: Record<string, unknown>): void;
802
+
803
+ /** Get time-travel history (if enabled). */
804
+ getHistory(): EngineEvent[];
805
+
806
+ /** Clean up subscriptions, timers, and listeners. */
807
+ dispose(): void;
808
+ }
809
+
810
+ /** Factory — preferred over `new VariantEngine()`. */
811
+ export function createEngine(
812
+ config: ExperimentsConfig,
813
+ options: EngineOptions
814
+ ): VariantEngine;
815
+
816
+ /** Events emitted by the engine. */
817
+ export type EngineEvent =
818
+ | { type: "ready"; config: ExperimentsConfig }
819
+ | { type: "assignment"; experimentId: string; variantId: string; context: VariantContext }
820
+ | { type: "exposure"; experimentId: string; variantId: string }
821
+ | { type: "variantChanged"; experimentId: string; variantId: string; source: "user" | "system" | "deeplink" | "qr" }
822
+ | { type: "rollback"; experimentId: string; variantId: string; reason: string }
823
+ | { type: "configLoaded"; config: ExperimentsConfig }
824
+ | { type: "error"; error: Error };
825
+ ```
826
+
827
+ ## Storage interface
828
+
829
+ ```ts
830
+ /** Pluggable storage adapter. All methods may be sync or async. */
831
+ export interface Storage {
832
+ getItem(key: string): string | null | Promise<string | null>;
833
+ setItem(key: string, value: string): void | Promise<void>;
834
+ removeItem(key: string): void | Promise<void>;
835
+ keys?(): string[] | Promise<string[]>;
836
+ }
837
+
838
+ /** In-memory storage, useful for tests and SSR. */
839
+ export function createMemoryStorage(): Storage;
840
+ ```
841
+
842
+ Adapter packages provide concrete implementations:
843
+
844
+ - `@variantlab/react-native` exports `AsyncStorageAdapter`, `MMKVStorageAdapter`, `SecureStoreAdapter`
845
+ - `@variantlab/react` exports `LocalStorageAdapter`, `SessionStorageAdapter`, `CookieStorageAdapter`
846
+ - `@variantlab/next` exports `NextCookieAdapter` (SSR-aware)
847
+
848
+ ## Fetcher interface
849
+
850
+ ```ts
851
+ /** Optional remote config fetcher. */
852
+ export interface Fetcher {
853
+ fetch(): Promise<ExperimentsConfig>;
854
+ pollInterval?: number;
855
+ }
856
+
857
+ /** Simple HTTP fetcher helper. */
858
+ export function createHttpFetcher(options: {
859
+ url: string;
860
+ headers?: Record<string, string>;
861
+ pollInterval?: number;
862
+ signal?: AbortSignal;
863
+ }): Fetcher;
864
+ ```
865
+
866
+ ## Telemetry interface
867
+
868
+ ```ts
869
+ /** Optional telemetry sink. Called for every engine event. */
870
+ export interface Telemetry {
871
+ track(event: EngineEvent): void;
872
+ }
873
+
874
+ /** Helper to combine multiple telemetry sinks. */
875
+ export function combineTelemetry(...sinks: Telemetry[]): Telemetry;
876
+ ```
877
+
878
+ ## Targeting API
879
+
880
+ ```ts
881
+ /** Evaluate a targeting predicate against a context. */
882
+ export function evaluate(
883
+ targeting: EvaluableTargeting,
884
+ context: VariantContext | EvalContext,
885
+ ): TargetingResult;
886
+
887
+ /** Thin wrapper returning evaluate(...).matched. */
888
+ export function matchTargeting(
889
+ targeting: EvaluableTargeting,
890
+ context: VariantContext | EvalContext,
891
+ ): boolean;
892
+
893
+ /** Walk an experiment's targeting and return a full trace. */
894
+ export function explain(
895
+ experiment: Experiment,
896
+ context: VariantContext | EvalContext,
897
+ ): ExplainResult;
898
+
899
+ /** Match a route pattern. Supports /foo, /foo/*, /foo/**, /user/:id. */
900
+ export function matchRoute(pattern: string, route: string): boolean;
901
+
902
+ /** Match a semver range. Supports >=1.2.0, ^1.2.0, 1.2.0 - 2.0.0. */
903
+ export function matchSemver(range: string, version: string): boolean;
904
+
905
+ /** Compute a sha256 bucket (0..99) for a userId. Async (Web Crypto). */
906
+ export function hashUserId(userId: string): Promise<number>;
907
+ ```
908
+
909
+ ## Assignment API
910
+
911
+ ```ts
912
+ /** Deterministic hash of (userId + experimentId) to a [0, 1) float. */
913
+ export function stickyHash(userId: string, experimentId: string): number;
914
+
915
+ /** Evaluate an assignment strategy. */
916
+ export function assignVariant(
917
+ experiment: Experiment,
918
+ context: VariantContext
919
+ ): string;
920
+ ```
921
+
922
+ ## Errors
923
+
924
+ ```ts
925
+ /** A single validation issue surfaced by validateConfig. */
926
+ export interface ConfigIssue {
927
+ readonly path: string; // RFC 6901 JSON Pointer
928
+ readonly code: IssueCode; // machine-readable
929
+ readonly message: string; // human-readable
930
+ }
931
+
932
+ /** Thrown when config validation fails. */
933
+ export class ConfigValidationError extends Error {
934
+ readonly issues: ReadonlyArray<ConfigIssue>;
935
+ }
936
+
937
+ /** Thrown when HMAC verification fails. */
938
+ export class SignatureVerificationError extends Error {}
939
+
940
+ /** Thrown when an experiment ID is unknown (fail-closed mode). */
941
+ export class UnknownExperimentError extends Error {
942
+ readonly experimentId: string;
943
+ }
944
+ ```
945
+
946
+ ## React API (`@variantlab/react`)
947
+
948
+ ### Provider
949
+
950
+ ```tsx
951
+ export interface VariantLabProviderProps {
952
+ config: ExperimentsConfig;
953
+ options?: Omit<EngineOptions, "storage"> & { storage?: Storage };
954
+ context?: Partial<VariantContext>;
955
+ children: React.ReactNode;
956
+ }
957
+
958
+ export const VariantLabProvider: React.FC<VariantLabProviderProps>;
959
+ ```
960
+
961
+ ### Hooks
962
+
963
+ ```ts
964
+ /** Returns the current variant ID for an experiment. */
965
+ export function useVariant(experimentId: string): string;
966
+
967
+ /** Returns the variant value (for "value" experiments). */
968
+ export function useVariantValue<T = unknown>(experimentId: string): T;
969
+
970
+ /** Returns { variant, value, track }. */
971
+ export function useExperiment<T = unknown>(experimentId: string): {
972
+ variant: string;
973
+ value: T;
974
+ track: (eventName: string, properties?: Record<string, unknown>) => void;
975
+ };
976
+
977
+ /** Imperative variant setter. Dev-only by default. */
978
+ export function useSetVariant(): (experimentId: string, variantId: string) => void;
979
+
980
+ /** Low-level engine access. */
981
+ export function useVariantLabEngine(): VariantEngine;
982
+
983
+ /** Returns experiments applicable to the current route. */
984
+ export function useRouteExperiments(route?: string): Experiment[];
985
+ ```
986
+
987
+ ### Components
988
+
989
+ ```tsx
990
+ /** Render-prop switch for "render" experiments. */
991
+ export const Variant: React.FC<{
992
+ experimentId: string;
993
+ children: Record<string, React.ReactNode>;
994
+ fallback?: React.ReactNode;
995
+ }>;
996
+
997
+ /** Render-prop for "value" experiments. */
998
+ export function VariantValue<T>(props: {
999
+ experimentId: string;
1000
+ children: (value: T) => React.ReactNode;
1001
+ }): React.ReactElement;
1002
+
1003
+ /** Error boundary that reports crashes to the engine. */
1004
+ export const VariantErrorBoundary: React.ComponentType<{
1005
+ experimentId: string;
1006
+ fallback?: React.ReactNode | ((error: Error) => React.ReactNode);
1007
+ children: React.ReactNode;
1008
+ }>;
1009
+ ```
1010
+
1011
+ ## React Native API (`@variantlab/react-native`)
1012
+
1013
+ Re-exports everything from `@variantlab/react` plus:
1014
+
1015
+ ### Storage adapters
1016
+
1017
+ ```ts
1018
+ export function createAsyncStorageAdapter(): Storage;
1019
+ export function createMMKVStorageAdapter(): Storage;
1020
+ export function createSecureStoreAdapter(): Storage;
1021
+ ```
1022
+
1023
+ ### Debug overlay (React Native — `@variantlab/react-native/debug`)
1024
+
1025
+ ```tsx
1026
+ export const VariantDebugOverlay: React.FC<{
1027
+ forceEnable?: boolean;
1028
+ routeFilter?: boolean;
1029
+ position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
1030
+ hideButton?: boolean;
1031
+ }>;
1032
+
1033
+ export function openDebugOverlay(): void;
1034
+ export function closeDebugOverlay(): void;
1035
+ ```
1036
+
1037
+ ### Debug overlay (React Web — `@variantlab/react/debug`)
1038
+
1039
+ ```tsx
1040
+ export const VariantDebugOverlay: React.FC<{
1041
+ forceEnable?: boolean;
1042
+ routeFilter?: boolean;
1043
+ position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
1044
+ hideButton?: boolean;
1045
+ theme?: Partial<OverlayTheme>;
1046
+ offset?: { x: number; y: number };
1047
+ }>;
1048
+
1049
+ export function openDebugOverlay(): void;
1050
+ export function closeDebugOverlay(): void;
1051
+ ```
1052
+
1053
+ Also re-exported from `@variantlab/next/debug` with `"use client"` directive.
1054
+
1055
+ ### Deep link handler
1056
+
1057
+ ```ts
1058
+ export function registerDeepLinkHandler(
1059
+ engine: VariantEngine,
1060
+ options?: { scheme?: string; host?: string }
1061
+ ): () => void;
1062
+ ```
1063
+
1064
+ ## Next.js API (`@variantlab/next`)
1065
+
1066
+ ### Server
1067
+
1068
+ ```ts
1069
+ export function createVariantLabServer(
1070
+ config: ExperimentsConfig,
1071
+ options?: Omit<EngineOptions, "storage"> & { storage?: Storage }
1072
+ ): VariantEngine;
1073
+
1074
+ export function getVariantSSR(
1075
+ experimentId: string,
1076
+ request: Request | NextApiRequest,
1077
+ config: ExperimentsConfig
1078
+ ): string;
1079
+
1080
+ export function getVariantValueSSR<T = unknown>(
1081
+ experimentId: string,
1082
+ request: Request | NextApiRequest,
1083
+ config: ExperimentsConfig
1084
+ ): T;
1085
+ ```
1086
+
1087
+ ### Middleware
1088
+
1089
+ ```ts
1090
+ export function variantLabMiddleware(config: ExperimentsConfig): (
1091
+ req: NextRequest
1092
+ ) => NextResponse;
1093
+ ```
1094
+
1095
+ ## CLI commands
1096
+
1097
+ ```
1098
+ variantlab init Scaffold experiments.json + install adapter
1099
+ variantlab generate [--out <path>] Generate .d.ts from experiments.json
1100
+ variantlab validate [--config <path>] Validate config + check for orphaned IDs
1101
+ variantlab eval <experiment> [context] Evaluate a single experiment with a context
1102
+ ```
1103
+
1104
+ ---
1105
+
1106
+ # Architecture
1107
+
1108
+ ## Design goals
1109
+
1110
+ 1. **Core runs anywhere.** Any ECMAScript 2020 environment — Node 18+, Deno, Bun, browsers, React Native Hermes, Cloudflare Workers, Vercel Edge, AWS Lambda@Edge.
1111
+ 2. **Adapters are trivially small.** Each framework adapter is < 200 source LOC and < 2 KB gzipped.
1112
+ 3. **Tree-shakeable everything.** Every export lives in its own module file. Unused code is eliminated at build time.
1113
+ 4. **No implicit IO.** Core never reads from disk, network, or global storage. All IO happens through injected adapters (Storage, Fetcher, Telemetry).
1114
+ 5. **Deterministic at hash boundaries.** Same `userId` + experiment = same variant across every runtime.
1115
+ 6. **Forward-compatible config schema.** `experiments.json` has a `version` field. The engine refuses unknown versions.
1116
+
1117
+ ## Runtime data flow
1118
+
1119
+ ```
1120
+ +-------------------------------------------------------------------+
1121
+ | Application code (framework) |
1122
+ | |
1123
+ | useVariant("x") <Variant experimentId="x"> track(...) |
1124
+ +---------+------------------------+------------------------+--------+
1125
+ | | |
1126
+ +---------v------------------------v------------------------v--------+
1127
+ | Framework adapter |
1128
+ | (@variantlab/react, /next, ...) |
1129
+ | |
1130
+ | React Context | Hooks | SSR helpers | Debug overlay |
1131
+ +---------+----------------------------------------------------------+
1132
+ |
1133
+ | subscribe / getVariant / setVariant / trackEvent
1134
+ |
1135
+ +---------v----------------------------------------------------------+
1136
+ | @variantlab/core |
1137
+ | |
1138
+ | +------------+ +------------+ +------------+ +----------+ |
1139
+ | | Engine |--| Targeting |--| Assignment |--| Schema | |
1140
+ | | | | | | | | validator| |
1141
+ | +-----+------+ +------------+ +------------+ +----------+ |
1142
+ | | |
1143
+ | +-----v------+ +------------+ +------------+ +----------+ |
1144
+ | | Storage | | Fetcher | | Telemetry | | Crypto | |
1145
+ | | (injected) | | (injected) | | (injected) | | (WebAPI) | |
1146
+ | +------------+ +------------+ +------------+ +----------+ |
1147
+ +--------------------------------------------------------------------+
1148
+ ```
1149
+
1150
+ ### Resolve variant (hot path)
1151
+
1152
+ Called on every `getVariant()` read. Must be O(1).
1153
+
1154
+ 1. Engine checks in-memory override map (dev/debug overrides win)
1155
+ 2. Engine checks Storage for a persisted assignment
1156
+ 3. If none, engine evaluates targeting predicates against `context`
1157
+ 4. If targeting passes, engine runs the assignment strategy
1158
+ 5. Engine writes the result to Storage and memoizes
1159
+ 6. Engine emits an `onAssignment` event to Telemetry (first time only per session)
1160
+ 7. Returns variant ID
1161
+
1162
+ ### Load config (cold start)
1163
+
1164
+ 1. App mounts provider with inline config or async `Fetcher`
1165
+ 2. Engine validates config (hand-rolled validator, no zod)
1166
+ 3. If HMAC signature is present, engine verifies via Web Crypto
1167
+ 4. Engine hydrates Storage — reads all previously persisted assignments
1168
+ 5. Engine emits `onReady` event
1169
+
1170
+ ### Override flow (dev / QA)
1171
+
1172
+ 1. User taps a variant in debug overlay, or deep link fires, or QR is scanned
1173
+ 2. Adapter calls `engine.setVariant(experimentId, variantId)`
1174
+ 3. Engine writes override to Storage with priority flag
1175
+ 4. Engine emits `onVariantChanged`
1176
+ 5. All subscribed components re-render via `useSyncExternalStore`
1177
+
1178
+ ### Crash rollback flow
1179
+
1180
+ 1. `<VariantErrorBoundary>` catches an error
1181
+ 2. Adapter calls `engine.reportCrash(experimentId, error)`
1182
+ 3. Engine increments crash counter in Storage
1183
+ 4. If counter exceeds threshold within window, engine forces the default variant and emits `onRollback`
1184
+
1185
+ ## Package boundaries
1186
+
1187
+ | Package | Size budget (gzip) | Runtime deps | Description |
1188
+ |---|---:|---|---|
1189
+ | `@variantlab/core` | **3.0 KB** | 0 | Engine, targeting, assignment |
1190
+ | `@variantlab/react` | **1.5 KB** | core | React 18/19 hooks + components |
1191
+ | `@variantlab/react-native` | **4.0 KB** | core, react | RN bindings + debug overlay |
1192
+ | `@variantlab/next` | **2.0 KB** | core, react | Next.js 14/15 SSR + Edge |
1193
+ | `@variantlab/cli` | — | core | CLI tool (dev dependency) |
1194
+
1195
+ ## Build tooling
1196
+
1197
+ | Tool | Purpose |
1198
+ |---|---|
1199
+ | **pnpm** | Package manager + workspace |
1200
+ | **tsup** | Bundle (ESM+CJS+DTS via esbuild) |
1201
+ | **TypeScript 5.6+** | Type checking (strict mode) |
1202
+ | **Vitest** | Unit + integration tests |
1203
+ | **size-limit** | Bundle size enforcement in CI |
1204
+ | **Changesets** | Per-package semver versioning |
1205
+ | **Biome** | Lint + format (30x faster than ESLint) |
1206
+
1207
+ ## Dependency policy
1208
+
1209
+ - **Core**: zero runtime dependencies, forever. Enforced by CI.
1210
+ - **Adapters**: `@variantlab/core` only. Everything else is peer.
1211
+ - Every runtime dependency is a supply-chain attack vector. By refusing all runtime deps in core, the audit surface is our own code.
1212
+
1213
+ ---
1214
+
1215
+ # Design principles
1216
+
1217
+ The 8 principles that govern every design decision in variantlab.
1218
+
1219
+ ### 1. Framework-agnostic core, thin adapters
1220
+
1221
+ The engine runs in any ECMAScript environment. Every framework binding is a thin wrapper (< 200 LOC). `@variantlab/core` never imports `react`, `vue`, `svelte`, or any framework.
1222
+
1223
+ ### 2. Zero runtime dependencies
1224
+
1225
+ `@variantlab/core` has zero runtime dependencies. We hand-roll our schema validator (400 bytes vs zod's 12 KB), semver matcher (250 bytes vs `semver`'s 6 KB), route glob matcher (150 bytes vs `minimatch`'s 4 KB), and hash function (80 bytes vs `murmurhash`'s 500 bytes).
1226
+
1227
+ ### 3. ESM-first, tree-shakeable, edge-compatible
1228
+
1229
+ All packages ship ES modules with `"sideEffects": false`. ES2020 target. Dual ESM+CJS output. No Node built-ins in core. Runs in Node 18+, Deno, Bun, browsers, React Native Hermes, Cloudflare Workers, Vercel Edge, AWS Lambda@Edge.
1230
+
1231
+ ### 4. Security by construction
1232
+
1233
+ No `eval`, no `Function()`, no dynamic `import()` on config data. Prototype pollution blocked via `Object.create(null)` and key allow-lists. Constant-time HMAC via Web Crypto. Hard limits on config size, nesting, and iteration. Config frozen after load via `Object.freeze`.
1234
+
1235
+ ### 5. Declarative JSON as the contract
1236
+
1237
+ `experiments.json` is the single source of truth. JSON configs are version-controllable, reviewable, toolable, portable, and safe.
1238
+
1239
+ ### 6. SSR correct everywhere
1240
+
1241
+ The engine is deterministic. Same config + context = same variant, every time. No `Math.random()` in hot paths. No hydration mismatches in Next.js App Router, Remix, SvelteKit, SolidStart, or Nuxt.
1242
+
1243
+ ### 7. Privacy by default
1244
+
1245
+ Zero data collection. No phone-home on import. No analytics, error tracking, or usage stats. Every network call is explicit and user-provided. GDPR/CCPA/LGPD compliant out of the box.
1246
+
1247
+ ### 8. Docs-first development
1248
+
1249
+ Every public API is specified in markdown before code is written. `API.md` is authoritative. Features have specs before implementation. Every phase has a plan with exit criteria.
1250
+
1251
+ ### Priority order when principles conflict
1252
+
1253
+ Security > Privacy > Zero-dependency > SSR correctness > Framework-agnostic > Bundle size
1254
+
1255
+ ---
1256
+
1257
+ # Security
1258
+
1259
+ ## Threat model
1260
+
1261
+ | Threat | Description | Mitigation |
1262
+ |---|---|---|
1263
+ | **Malicious remote config** | Compromised CDN injects bad variants | Optional HMAC-SHA256 signed configs. Verify with Web Crypto before applying. |
1264
+ | **Tampered local storage** | Malicious app writes arbitrary keys | Stored variants validated against config. Unknown IDs discarded. |
1265
+ | **Config-based XSS** | Executable code in config | No `eval`, no `Function()`, no dynamic `import()` on config data. |
1266
+ | **Prototype pollution** | Crafted JSON with `__proto__` keys | `Object.create(null)` for parsed objects. Reserved keys rejected. |
1267
+ | **Large/malicious config DoS** | Exponential patterns, huge arrays | Hard limits: 1 MB config, 100 variants, 1000 experiments, depth 10. |
1268
+ | **HMAC timing attack** | Observe timing to guess bytes | `crypto.subtle.verify` is constant-time by spec. |
1269
+ | **Supply chain attack** | Compromised npm package | Zero runtime deps in core. Signed releases via provenance + Sigstore. |
1270
+ | **Debug overlay in production** | End users see internal debug UI | Overlay tree-shaken in production. Throws unless `NODE_ENV === "development"`. |
1271
+ | **Deep link abuse** | Force users into broken variants | Deep links off by default. Only `overridable: true` experiments. Session-scoped. |
1272
+ | **Storage key collision** | Another library writes same keys | All keys prefixed with `variantlab:v1:`. Corrupted values discarded. |
1273
+
1274
+ ## Security commitments
1275
+
1276
+ 1. **Never add telemetry** that reports to a server we control.
1277
+ 2. **Never add auto-update** mechanisms that fetch new code at runtime.
1278
+ 3. **Never phone home** on import. The engine does nothing on module load.
1279
+ 4. **Publish a full SBOM** with every release.
1280
+ 5. **Sign every release** via Sigstore and npm provenance.
1281
+ 6. **Respond to security reports within 48 hours.**
1282
+
1283
+ ## Privacy commitments
1284
+
1285
+ 1. Zero data collection about users, developers, or their apps.
1286
+ 2. Zero network requests on its own. Every call from user-provided adapters.
1287
+ 3. GDPR / CCPA / LGPD compliant out of the box — no data to collect.
1288
+ 4. User IDs hashed client-side before any network call.
1289
+ 5. Debug overlay state stored locally only.
1290
+
1291
+ ## CSP compatibility
1292
+
1293
+ Works under the most restrictive Content Security Policies:
1294
+
1295
+ ```
1296
+ Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'
1297
+ ```
1298
+
1299
+ No inline scripts, no inline styles, no `unsafe-eval`, no `unsafe-inline`.
1300
+
1301
+ ## Reporting a vulnerability
1302
+
1303
+ **Do not file public GitHub issues for security vulnerabilities.**
1304
+
1305
+ Use GitHub Security Advisories or email `security@variantlab.dev`.
1306
+
1307
+ We will:
1308
+ 1. Acknowledge receipt within 48 hours
1309
+ 2. Provide initial assessment within 7 days
1310
+ 3. Follow a 90-day coordinated disclosure window
1311
+
1312
+ ---
1313
+
1314
+ # Roadmap
1315
+
1316
+ ## Phase 1: MVP (v0.1) — Complete
1317
+
1318
+ - `@variantlab/core` — engine, targeting, assignment
1319
+ - `@variantlab/react` — hooks, components
1320
+ - `@variantlab/react-native` — RN bindings, storage adapters, debug overlay
1321
+ - `@variantlab/next` — App Router + Pages Router SSR
1322
+ - `@variantlab/cli` — `init`, `generate`, `validate`, `eval`
1323
+
1324
+ ## Phase 2: Expansion (v0.2)
1325
+
1326
+ - `@variantlab/remix` — Remix loaders, actions, cookie stickiness
1327
+ - `@variantlab/vue` — Vue 3 composables + components
1328
+ - `@variantlab/vanilla` — plain JS/TS helpers
1329
+ - `@variantlab/devtools` — Chrome/Firefox browser extension
1330
+ - ~~React web `VariantDebugOverlay`~~ — **Done** (available in `@variantlab/react/debug` and `@variantlab/next/debug`)
1331
+
1332
+ ## Phase 3: Ecosystem (v0.3)
1333
+
1334
+ - `@variantlab/svelte` — Svelte 5 stores + SvelteKit
1335
+ - `@variantlab/solid` — SolidJS signals + SolidStart
1336
+ - `@variantlab/astro` — Astro integration
1337
+ - `@variantlab/nuxt` — Nuxt module
1338
+ - `@variantlab/storybook` — Storybook 8 addon
1339
+ - `@variantlab/eslint-plugin` — lint rules
1340
+ - `@variantlab/test-utils` — Jest/Vitest/Playwright helpers
1341
+
1342
+ ## Phase 4: Advanced (v0.4)
1343
+
1344
+ - HMAC signing GA with CLI tooling
1345
+ - Crash-triggered rollback GA
1346
+ - Time-travel debugger
1347
+ - QR code state sharing
1348
+ - Multivariate crossed experiments
1349
+ - Holdout groups
1350
+ - Mutual exclusion groups GA
1351
+
1352
+ ## Phase 5: v1.0 Stable
1353
+
1354
+ - API freeze with semver strict
1355
+ - Third-party security audit
1356
+ - Reproducible builds
1357
+ - Long-term support policy
1358
+ - Migration guides from Firebase Remote Config, GrowthBook, Statsig, LaunchDarkly
1359
+
1360
+ ## Versioning commitments
1361
+
1362
+ | Version range | Stability | Breaking changes |
1363
+ |---|---|---|
1364
+ | 0.0.x | Experimental | Any time |
1365
+ | 0.1.x - 0.4.x | Beta | Minor versions can break |
1366
+ | 0.5.x - 0.9.x | Release candidate | Patch only for security |
1367
+ | 1.0.0+ | Stable | Semver strict — major version required |
1368
+
1369
+ ---
231
1370
 
232
1371
  ## License
233
1372
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@variantlab/core",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "The framework-agnostic variantlab engine. Zero dependencies, runs anywhere.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,8 +27,14 @@
27
27
  "README.md"
28
28
  ],
29
29
  "publishConfig": {
30
- "access": "public",
31
- "provenance": false
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "build": "tsup",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "pnpm --dir ../.. exec vitest run --project core",
36
+ "test:coverage": "pnpm --dir ../.. exec vitest run --coverage --project core",
37
+ "clean": "rm -rf dist"
32
38
  },
33
39
  "keywords": [
34
40
  "ab-testing",
@@ -48,12 +54,5 @@
48
54
  "homepage": "https://github.com/Minhaj-Rabby/variantlab#readme",
49
55
  "engines": {
50
56
  "node": ">=18.17"
51
- },
52
- "scripts": {
53
- "build": "tsup",
54
- "typecheck": "tsc --noEmit",
55
- "test": "pnpm --dir ../.. exec vitest run --project core",
56
- "test:coverage": "pnpm --dir ../.. exec vitest run --coverage --project core",
57
- "clean": "rm -rf dist"
58
57
  }
59
- }
58
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 variantlab contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.