@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
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# API philosophy
|
|
2
|
+
|
|
3
|
+
Why the variantlab public API looks the way it does. This document is about design taste, not feature specs. If you disagree with a choice here, open a discussion — but first, understand the reasoning.
|
|
4
|
+
|
|
5
|
+
## Table of contents
|
|
6
|
+
|
|
7
|
+
- [Core tenets](#core-tenets)
|
|
8
|
+
- [Hook-first design](#hook-first-design)
|
|
9
|
+
- [Render-prop for component swaps](#render-prop-for-component-swaps)
|
|
10
|
+
- [Why not a single mega-hook](#why-not-a-single-mega-hook)
|
|
11
|
+
- [Why generics over function overloads](#why-generics-over-function-overloads)
|
|
12
|
+
- [Error handling philosophy](#error-handling-philosophy)
|
|
13
|
+
- [Naming conventions](#naming-conventions)
|
|
14
|
+
- [Opinions we deliberately avoid](#opinions-we-deliberately-avoid)
|
|
15
|
+
- [Influences](#influences)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Core tenets
|
|
20
|
+
|
|
21
|
+
1. **If you can't learn it in 10 minutes, it's wrong.**
|
|
22
|
+
2. **Type safety is a feature, not a compromise.**
|
|
23
|
+
3. **One obvious way to do things.**
|
|
24
|
+
4. **Hooks for values, components for rendering.**
|
|
25
|
+
5. **Errors are values until they're fatal.**
|
|
26
|
+
6. **Never surprise the user.**
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Hook-first design
|
|
31
|
+
|
|
32
|
+
### Decision
|
|
33
|
+
|
|
34
|
+
The primary API is hooks, not classes or singletons.
|
|
35
|
+
|
|
36
|
+
### Why
|
|
37
|
+
|
|
38
|
+
React hooks are the native React idiom. Vue composables, Svelte stores, Solid signals, and Nuxt composables all follow the same pattern. A hook-first API maps 1:1 across frameworks.
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
// React
|
|
42
|
+
const variant = useVariant("cta-copy");
|
|
43
|
+
|
|
44
|
+
// Vue 3
|
|
45
|
+
const variant = useVariant("cta-copy");
|
|
46
|
+
|
|
47
|
+
// Svelte 5
|
|
48
|
+
const variant = $derived(useVariant("cta-copy"));
|
|
49
|
+
|
|
50
|
+
// Solid
|
|
51
|
+
const variant = useVariant("cta-copy");
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Same signature, same mental model, different reactive semantics under the hood.
|
|
55
|
+
|
|
56
|
+
### What we rejected
|
|
57
|
+
|
|
58
|
+
- **Class-based API** — `new VariantLab().getVariant()`. Too OOP, doesn't fit modern frontend.
|
|
59
|
+
- **Singleton module** — `import vl from 'variantlab'; vl.getVariant()`. Impossible to test, impossible to SSR cleanly.
|
|
60
|
+
- **Provider injection** — `@inject VariantLab`. Angular-style, foreign to React/Vue/Svelte devs.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Render-prop for component swaps
|
|
65
|
+
|
|
66
|
+
### Decision
|
|
67
|
+
|
|
68
|
+
Component-swap experiments use a render-prop component:
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
<Variant experimentId="news-card-layout">
|
|
72
|
+
{{
|
|
73
|
+
responsive: <ResponsiveCard />,
|
|
74
|
+
"scale-to-fit": <ScaleToFitCard />,
|
|
75
|
+
"pip-thumbnail": <PipCard />,
|
|
76
|
+
}}
|
|
77
|
+
</Variant>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Why
|
|
81
|
+
|
|
82
|
+
The alternative is imperative:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
const variant = useVariant("news-card-layout");
|
|
86
|
+
if (variant === "responsive") return <ResponsiveCard />;
|
|
87
|
+
if (variant === "scale-to-fit") return <ScaleToFitCard />;
|
|
88
|
+
if (variant === "pip-thumbnail") return <PipCard />;
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Both work. The render-prop version is better because:
|
|
92
|
+
|
|
93
|
+
1. **Exhaustiveness**: TypeScript can verify that every variant ID has a component
|
|
94
|
+
2. **Fallback**: `fallback` prop handles unknown variants and errors
|
|
95
|
+
3. **Consistency**: same pattern across all framework adapters
|
|
96
|
+
4. **Discoverability**: one grep finds every experiment in the codebase
|
|
97
|
+
|
|
98
|
+
### Test
|
|
99
|
+
|
|
100
|
+
Can TypeScript catch a missing variant at compile time? Yes, via the codegen'd types:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<Variant experimentId="news-card-layout">
|
|
104
|
+
{{
|
|
105
|
+
responsive: <ResponsiveCard />,
|
|
106
|
+
"scale-to-fit": <ScaleToFitCard />,
|
|
107
|
+
// ❌ Error: Property "pip-thumbnail" is missing in type
|
|
108
|
+
}}
|
|
109
|
+
</Variant>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Why not a single mega-hook
|
|
115
|
+
|
|
116
|
+
### Decision
|
|
117
|
+
|
|
118
|
+
We ship multiple narrow hooks instead of one that does everything:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
useVariant(id) // returns variant ID string
|
|
122
|
+
useVariantValue<T>(id) // returns the value
|
|
123
|
+
useExperiment<T>(id) // returns { variant, value, track }
|
|
124
|
+
useRouteExperiments() // returns experiments for current route
|
|
125
|
+
useSetVariant() // imperative setter (dev only)
|
|
126
|
+
useVariantLabEngine() // low-level engine access
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Why
|
|
130
|
+
|
|
131
|
+
A single hook like `useVariantLab(id)` returning everything would be:
|
|
132
|
+
|
|
133
|
+
- Harder to type (what does it return?)
|
|
134
|
+
- Harder to tree-shake (pulls in all features even if unused)
|
|
135
|
+
- Confusing (users don't know what they get)
|
|
136
|
+
|
|
137
|
+
Narrow hooks:
|
|
138
|
+
|
|
139
|
+
- Each has one clear purpose
|
|
140
|
+
- Each has one return type
|
|
141
|
+
- Bundlers can tree-shake unused ones
|
|
142
|
+
- IDE autocomplete is meaningful
|
|
143
|
+
|
|
144
|
+
### The "one obvious way" principle
|
|
145
|
+
|
|
146
|
+
For any given task, there should be one obvious hook to use:
|
|
147
|
+
|
|
148
|
+
| Task | Hook |
|
|
149
|
+
|---|---|
|
|
150
|
+
| Read which variant is active | `useVariant` |
|
|
151
|
+
| Read a variant's value | `useVariantValue` |
|
|
152
|
+
| Track an exposure event | `useExperiment` |
|
|
153
|
+
| Get all experiments on the current page | `useRouteExperiments` |
|
|
154
|
+
| Change a variant from code (dev) | `useSetVariant` |
|
|
155
|
+
| Low-level access | `useVariantLabEngine` |
|
|
156
|
+
|
|
157
|
+
If you find yourself wondering "which hook do I use?", we failed. Open an issue.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Why generics over function overloads
|
|
162
|
+
|
|
163
|
+
### Decision
|
|
164
|
+
|
|
165
|
+
`useVariantValue<T>(id)` takes a type parameter for the return value, not multiple overloads.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// This:
|
|
169
|
+
const price = useVariantValue<number>("pricing");
|
|
170
|
+
|
|
171
|
+
// Not this:
|
|
172
|
+
const price = useVariantValueNumber("pricing");
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Why
|
|
176
|
+
|
|
177
|
+
- One function name instead of `useVariantValueString`, `useVariantValueNumber`, `useVariantValueBoolean`, etc.
|
|
178
|
+
- Works with codegen: `useVariantValue("pricing")` narrows to `number` automatically when the experiment type is generated.
|
|
179
|
+
- Users can always widen or narrow explicitly.
|
|
180
|
+
|
|
181
|
+
### When codegen is active
|
|
182
|
+
|
|
183
|
+
With the generated types, the generic is inferred:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// Codegen knows cta-copy is a string experiment
|
|
187
|
+
const copy = useVariantValue("cta-copy"); // inferred: "Buy now" | "Get started" | "Try it free"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### When codegen is not active
|
|
191
|
+
|
|
192
|
+
Users can provide the type manually:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const copy = useVariantValue<string>("cta-copy"); // string
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Error handling philosophy
|
|
201
|
+
|
|
202
|
+
### Decision
|
|
203
|
+
|
|
204
|
+
Errors are values until they're fatal. The engine never throws on expected failures; it returns a default.
|
|
205
|
+
|
|
206
|
+
### The modes
|
|
207
|
+
|
|
208
|
+
- **fail-open** (default): Any error during resolution returns the experiment's default variant. Logs a warning.
|
|
209
|
+
- **fail-closed**: Any error throws. For apps that prefer loud failures over silent defaults.
|
|
210
|
+
|
|
211
|
+
### What throws in fail-closed mode
|
|
212
|
+
|
|
213
|
+
- `ConfigValidationError` — config doesn't match schema
|
|
214
|
+
- `SignatureVerificationError` — HMAC fails
|
|
215
|
+
- `UnknownExperimentError` — experiment ID not in config
|
|
216
|
+
|
|
217
|
+
### What never throws, even in fail-closed mode
|
|
218
|
+
|
|
219
|
+
- Normal variant resolution (falls back to default)
|
|
220
|
+
- Targeting evaluation (if a predicate throws, we catch it and log)
|
|
221
|
+
- Storage read failures (returns undefined)
|
|
222
|
+
|
|
223
|
+
### Why fail-open by default
|
|
224
|
+
|
|
225
|
+
Because you don't want your app to crash because a feature flag config has a typo. The worst realistic outcome should be "user sees the default variant", never "white screen of death".
|
|
226
|
+
|
|
227
|
+
### Why fail-closed as an option
|
|
228
|
+
|
|
229
|
+
Because some teams prefer loud failures in tests or staging. It's a one-line config change.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Naming conventions
|
|
234
|
+
|
|
235
|
+
### Hooks start with `use`
|
|
236
|
+
|
|
237
|
+
Following React/Vue convention.
|
|
238
|
+
|
|
239
|
+
### Components are PascalCase
|
|
240
|
+
|
|
241
|
+
`<Variant>`, `<VariantLabProvider>`, `<VariantDebugOverlay>`.
|
|
242
|
+
|
|
243
|
+
### Function names are verbs
|
|
244
|
+
|
|
245
|
+
`createEngine`, `getVariantSSR`, `registerDeepLinkHandler`.
|
|
246
|
+
|
|
247
|
+
### Type names are nouns
|
|
248
|
+
|
|
249
|
+
`VariantEngine`, `Experiment`, `Variant`, `VariantContext`, `Targeting`.
|
|
250
|
+
|
|
251
|
+
### Constants are UPPER_SNAKE_CASE, but we have almost none
|
|
252
|
+
|
|
253
|
+
We avoid exporting constants. Users configure via the config, not magic numbers.
|
|
254
|
+
|
|
255
|
+
### Events are past-tense
|
|
256
|
+
|
|
257
|
+
`assignment`, `exposure`, `variantChanged`, `rollback`, `configLoaded`.
|
|
258
|
+
|
|
259
|
+
### Options are positional when obvious, named otherwise
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
createEngine(config, options); // 2 positional args, both clear
|
|
263
|
+
setVariant(experimentId, variantId); // 2 positional args, both clear
|
|
264
|
+
|
|
265
|
+
// Everything else uses named args via options objects
|
|
266
|
+
createHttpFetcher({ url, headers, pollInterval });
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Opinions we deliberately avoid
|
|
272
|
+
|
|
273
|
+
### We don't pick a telemetry provider
|
|
274
|
+
|
|
275
|
+
Users plug their own. We ship an interface, not an adapter for PostHog or Mixpanel. Including one would violate principle 7 (privacy by default).
|
|
276
|
+
|
|
277
|
+
### We don't pick a state management library
|
|
278
|
+
|
|
279
|
+
No Redux adapter, no Zustand adapter, no Jotai adapter. The engine exposes `subscribe` and that's enough. If users want to integrate with their state library, it's 10 lines of code.
|
|
280
|
+
|
|
281
|
+
### We don't pick a router
|
|
282
|
+
|
|
283
|
+
No dependency on Expo Router, React Router, Next Router, or anything else. We accept `route: string` as context and you pass whatever router you use.
|
|
284
|
+
|
|
285
|
+
### We don't pick a storage
|
|
286
|
+
|
|
287
|
+
AsyncStorage, MMKV, SecureStore, IndexedDB, localStorage, sessionStorage, cookies — all valid. We ship adapters for the common ones but the core has zero preferences.
|
|
288
|
+
|
|
289
|
+
### We don't assume a particular testing library
|
|
290
|
+
|
|
291
|
+
Test utilities work with Jest, Vitest, @testing-library/react, @testing-library/vue, Playwright. No hardcoded assumptions.
|
|
292
|
+
|
|
293
|
+
### We don't assume a particular build tool
|
|
294
|
+
|
|
295
|
+
Webpack, Rollup, esbuild, swc, tsc, Vite, Parcel — all work. We ship standard ESM+CJS with .d.ts.
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## Influences
|
|
300
|
+
|
|
301
|
+
Our API taste comes from libraries we admire:
|
|
302
|
+
|
|
303
|
+
- **TanStack Query** — hook-first, TypeScript-first, framework-agnostic core, narrow public surface
|
|
304
|
+
- **Zod** — one obvious way, great type inference, zero magic
|
|
305
|
+
- **tRPC** — end-to-end type safety via codegen
|
|
306
|
+
- **Radix UI** — render-prop components for composition
|
|
307
|
+
- **Jotai** — minimal surface area, composable primitives
|
|
308
|
+
- **Drizzle ORM** — thin wrappers over primitives, no ORM magic
|
|
309
|
+
- **Hono** — small, fast, runs everywhere
|
|
310
|
+
- **Valibot** — zero-dep, tree-shakable, hand-rolled from the start
|
|
311
|
+
|
|
312
|
+
Each of these libraries is opinionated about *design*, not about *implementation*. They all refuse to do more than their one thing, and do it better than libraries that try to do everything.
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Anti-influences
|
|
317
|
+
|
|
318
|
+
Libraries whose approach we consciously avoid:
|
|
319
|
+
|
|
320
|
+
- **Redux** (early versions) — too much boilerplate, too many abstractions
|
|
321
|
+
- **Apollo Client** — tries to be everything, ships enormous bundles
|
|
322
|
+
- **Firebase JS SDK** — couples unrelated concerns, hard to tree-shake
|
|
323
|
+
- **Moment.js** — kitchen-sink API, enormous bundle
|
|
324
|
+
- **Lodash** (monolithic) — imports everything or requires per-function imports
|
|
325
|
+
|
|
326
|
+
The lesson from each: scope creep kills libraries. variantlab stays in scope.
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## How to propose an API change
|
|
331
|
+
|
|
332
|
+
1. Open a GitHub discussion, not a PR
|
|
333
|
+
2. State the use case first, the API second
|
|
334
|
+
3. Show how existing APIs fall short
|
|
335
|
+
4. Propose 1-3 alternatives
|
|
336
|
+
5. Include bundle-size impact
|
|
337
|
+
6. Include how it interacts with the 8 design principles
|
|
338
|
+
|
|
339
|
+
We're willing to change the API. We're not willing to change principles.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## See also
|
|
344
|
+
|
|
345
|
+
- [`API.md`](../../API.md) — the canonical API surface
|
|
346
|
+
- [`docs/design/design-principles.md`](./design-principles.md) — the 8 principles
|
|
347
|
+
- [`docs/research/bundle-size-analysis.md`](../research/bundle-size-analysis.md) — why API decisions affect bundle size
|