ab-testing-lib
v0.1.1
Published
Lightweight A/B testing library for React with real-time updates via WebSocket
Maintainers
Readme
AB Testing Library
Легковесная библиотека для A/B тестирования и feature flags с детерминированным назначением вариантов, локальным хранением, обновлением конфигурации в реальном времени через WebSocket и синхронизацией состояния между вкладками.
Quick Start
npm install ab-testing-libТочки входа:
- Vanilla JS — импортируйте только из
"ab-testing-lib". React не требуется. - React — импортируйте из
"ab-testing-lib/react". В проекте должен быть установлен React^18.0.0или^19.0.0(peer dependency, опциональная при установке пакета).
Минимальная конфигурация
Клиенту обязательно нужен realtime-провайдер. Без него в конструкторе выбрасывается ошибка.
import { ABClient, LocalStorageAdapter, WebSocketProvider } from "ab-testing-lib"
const client = new ABClient({
realtime: new WebSocketProvider("ws://your-backend/ab-config"),
// storage по умолчанию — LocalStorageAdapter, можно подключить ваш собственный (должен имплементировать interface StorageAdapter)
// crossTabSync по умолчанию true в браузере, можно выключить (например, для тестов в окружении Node.js)
})Library overview
Возможности
- Эксперименты (A/B и мультивариантные) — ключ, варианты, веса, статус, доля трафика (
splitPercentage). - Feature flags — вкл/выкл для доли пользователей с детерминированным rollout.
- Детерминированное назначение — один и тот же пользователь стабильно получает один и тот же вариант (
hash(userId + experimentKey)). - Sticky assignment — ранее назначенный вариант сохраняется при обновлении конфига (веса, splitPercentage), если он по-прежнему входит в список вариантов; UI не «скачет» при изменениях с бэкенда.
- Rehydration — пользователь и варианты сохраняются в storage; при перезагрузке страницы состояние восстанавливается без пересчёта.
- Real-time обновления — конфигурация приходит по WebSocket; при изменении split/weights/status варианты пересчитываются и подписчики уведомляются.
- Синхронизация между вкладками — при изменении user/variants в другой вкладке (localStorage) текущая вкладка обновляет состояние и перерисовывает UI.
- Override — переопределение вариантов/флагов для QA (query-параметры, админка, свой
OverrideProvider). - Типизация — полная поддержка TypeScript; экспорт типов для конфигов и адаптеров.
React: обёртка приложения
Оберните дерево компонентов в ABProvider, чтобы использовать useExperiment, useFeatureFlag и useABClient:
import { ABProvider, LocalStorageAdapter, WebSocketProvider } from "ab-testing-lib/react"
import type { ABClientConfig } from "ab-testing-lib/react"
const config: ABClientConfig = {
storage: new LocalStorageAdapter(),
realtime: new WebSocketProvider("ws://localhost:3001"),
}
function App() {
return (
<ABProvider config={config}>
<YourApp />
</ABProvider>
)
}Важно: создавайте объект config один раз (вне компонента или через useMemo), иначе при каждом ре-рендере будет создаваться новый ABClient и вызываться destroy() у предыдущего.
Экспорты пакета
Точка входа "ab-testing-lib" (ядро, без React):
- Классы:
ABClient,ABClientError,LocalStorageAdapter,WebSocketProvider - Типы:
ABClientConfig,ConfigItem,Experiment,FeatureFlag,UserData,VariantCallback,WebSocketProviderOptions,InitializeUserOptions,Logger,OverrideProvider,ABClientErrorCode - Интерфейсы:
StorageAdapter,RealtimeProvider
Точка входа "ab-testing-lib/react" (ядро + React):
- Всё перечисленное выше плюс:
ABProvider,useABClient,useExperiment,useFeatureFlag
API documentation
ABClient
| Метод | Описание |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| initializeUser(data: UserData, options?: InitializeUserOptions) | Инициализирует пользователя. При совпадении id с сохранённым в storage восстанавливает данные оттуда. Сохраняет пользователя в storage и пересчитывает варианты. |
| updateUser(data: UserData, options?: { reassignVariant?: boolean }) | Обновляет данные пользователя (merge с текущими). Если reassignVariant: true — пересчитывает варианты и уведомляет подписчиков. |
| getVariant(experimentKey: string) | Возвращает вариант эксперимента (string \| null). Учитывает override. Требует предварительного initializeUser(). |
| isFeatureEnabled(featureKey: string) | Возвращает, включён ли feature flag. Учитывает override. Требует предварительного initializeUser(). |
| subscribe(experimentKey: string, callback: (variant: string \| null) => void) | Подписывается на изменения варианта/флага. Возвращает функцию отписки. Не требует инициализированного пользователя. |
| isUserInitialized() | Проверяет, инициализирован ли пользователь (без throw). |
| getUser() | Возвращает данные текущего пользователя (UserData \| null). Если пользователь не инициализирован — null. Бросает при вызове после destroy(). |
| resetUser() | Сбрасывает пользователя и варианты в storage и памяти; подписчики получают null. После вызова нужно снова вызвать initializeUser(). |
| destroy() | Отписывается от realtime, снимает listener storage, отключает WebSocket. После вызова вызовы методов клиента приводят к ABClientError (CLIENT_DESTROYED). |
UserData
type UserData = {
id?: string
email?: string
attributes?: Record<string, unknown>
}Если id не передан: при первом initializeUser сначала проверяется storage; если там есть сохранённый пользователь с id — используется он; иначе генерируется новый (crypto.randomUUID() или fallback).
React
| Экспорт | Описание |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| ABProvider | Провайдер контекста: создаёт один экземпляр ABClient из config, при размонтировании вызывает client.destroy(). |
| useABClient() | Возвращает экземпляр ABClient из контекста. Бросает ABClientError (MISSING_PROVIDER), если вызван вне ABProvider. |
| useExperiment(experimentKey: string) | Возвращает текущий вариант эксперимента (string \| null). Подписывается на обновления; при смене конфига по WebSocket значение обновляется. |
| useFeatureFlag(featureKey: string) | Возвращает boolean — включён ли флаг. Подписывается на обновления (внутри используется subscribe с преобразованием "on"/"off" в boolean). |
Конфигурация ABClient (ABClientConfig)
interface ABClientConfig {
storage?: StorageAdapter // по умолчанию: new LocalStorageAdapter()
realtime: RealtimeProvider // обязательно
crossTabSync?: boolean // true — синхронизация по storage event (по умолчанию true в браузере). При false (например, в Node/тестах) listener на storage не вешается.
logger?: Logger // опционально: warn/error для неизвестных ключей, сбоев WS и т.д.
override?: OverrideProvider // опционально: переопределение вариантов/флагов для QA
}StorageAdapter
interface StorageAdapter {
get<T>(key: string): T | null
set<T>(key: string, value: T): void
remove(key: string): void
}Ключи, которые использует библиотека: ab-testing:user, ab-testing:variants (см. константы в коде; при кастомном адаптере можно не зависеть от них, если не нужна cross-tab синхронизация через storage event).
RealtimeProvider
interface RealtimeProvider {
connect(): void
disconnect(): void
onConfigUpdate(cb: (config: ConfigItem[]) => void): () => void
}При получении новой конфигурации клиент передаёт её в ExperimentManager; варианты пересчитываются и подписчики уведомляются.
WebSocketProvider
new WebSocketProvider(url: string, options?: WebSocketProviderOptions)
interface WebSocketProviderOptions {
autoReconnect?: boolean // true — автопереподключение при обрыве
maxReconnectDelay?: number // 30000 мс — макс. задержка перед переподключением
initialReconnectDelay?: number // 1000 мс — начальная задержка
}Logger
interface Logger {
info?(message: string, context?: Record<string, unknown>): void
warn(message: string, context?: Record<string, unknown>): void
error(message: string, context?: Record<string, unknown>): void
}Используется для предупреждений об неизвестных ключах экспериментов/флагов и для ошибок (например, сбой подключения WebSocket, ошибка в callback обновления конфига). Если не передан — логи не выводятся.
OverrideProvider
interface OverrideProvider {
getOverride(key: string): string | boolean | null
}- Для эксперимента: возвращать строку (вариант) или
null. - Для feature flag: возвращать
booleanилиnull.
Пример: чтение из query (?ab_checkout-button=B) или из ответа админки.
Ошибки (ABClientError)
type ABClientErrorCode =
| "USER_NOT_INITIALIZED" // getVariant/isFeatureEnabled до initializeUser
| "CLIENT_DESTROYED" // вызов метода после destroy()
| "INVALID_CONFIG" // нет realtime, неверный callback в subscribe и т.д.
| "INVALID_EXPERIMENT_KEY" // пустой или не строка
| "MISSING_PROVIDER" // useABClient вне ABProviderОбработка:
try {
const v = client.getVariant("checkout")
} catch (e) {
if (e instanceof ABClientError && e.code === "USER_NOT_INITIALIZED") {
// вызвать initializeUser() и повторить или показать fallback
}
}Example usage snippets
Vanilla JS: инициализация и эксперимент
import { ABClient, LocalStorageAdapter, WebSocketProvider } from "ab-testing-lib"
const ab = new ABClient({
storage: new LocalStorageAdapter(),
realtime: new WebSocketProvider("ws://localhost:3001"),
})
ab.initializeUser({
id: "user-123",
attributes: { country: "KZ" },
})
const variant = ab.getVariant("checkout-button")
const unsubscribe = ab.subscribe("checkout-button", (newVariant) => {
console.log("Вариант изменён:", newVariant)
})Vanilla JS: feature flag
if (ab.isFeatureEnabled("new_dashboard")) {
renderNewDashboard()
}React: эксперимент и feature flag
function CheckoutButton() {
const variant = useExperiment("checkout-button")
const showBeta = useFeatureFlag("beta_ui")
const label = variant === "B" ? "Купить сейчас" : "Добавить в корзину"
return (
<>
<button className={variant === "B" ? "btn-primary" : "btn-default"}>{label}</button>
{showBeta && <span>Beta</span>}
</>
)
}React: условный рендер до инициализации пользователя
function App() {
const ab = useABClient()
const [ready, setReady] = useState(ab.isUserInitialized())
useEffect(() => {
if (ab.isUserInitialized()) setReady(true)
}, [ab])
if (!ready) {
return (
<LoginScreen
onLogin={(user) => {
ab.initializeUser(user)
setReady(true)
}}
/>
)
}
return <Dashboard />
}Смена пользователя и пересчёт вариантов
ab.resetUser()
ab.initializeUser({ id: "other-user", attributes: {} })
// getVariant / подписчики получают новые значенияПолучение данных текущего пользователя
const user = ab.getUser()
if (user) {
console.log(user.id, user.email, user.attributes)
}Обновление атрибутов без пересчёта вариантов
ab.updateUser({ attributes: { plan: "premium" } })
// варианты не меняютсяПересчёт вариантов при обновлении пользователя
ab.updateUser({ attributes: { segment: "vip" } }, { reassignVariant: true })How to simulate remote config updates
Обновления конфигурации приходят по WebSocket. Клиент принимает два формата сообщений.
Формат 1: объект с типом
{
"type": "config_update",
"experiments": [
{
"key": "checkout-button",
"variants": ["A", "B"],
"weights": [50, 50],
"status": "active",
"splitPercentage": 100
}
]
}Формат 2: массив экспериментов/флагов напрямую
[
{
"key": "checkout-button",
"variants": ["A", "B"],
"weights": [50, 50],
"status": "active"
},
{
"type": "feature_flag",
"key": "new_dashboard",
"rolloutPercentage": 50,
"status": "active"
}
]Структура эксперимента (Experiment)
| Поле | Тип | Обязательное | Описание |
| ----------------- | -------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| key | string | да | Уникальный ключ эксперимента |
| variants | string[] | да | Список вариантов (например ["A", "B"]) |
| weights | number[] | нет | Веса вариантов (длина = variants.length). По умолчанию равные доли |
| status | "active" | "disabled" | нет | disabled — эксперимент не участвует, все получают null |
| splitPercentage | number (0–100) | нет | Доля пользователей, участвующих в эксперименте. 100 — все; 0 — никто. Детерминировано по пользователю. Sticky: уже попавшие в эксперимент пользователи сохраняют вариант при уменьшении доли. |
Структура feature flag (FeatureFlag)
| Поле | Тип | Обязательное | Описание |
| ------------------- | -------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| type | "feature_flag" | да | Отличие от эксперимента |
| key | string | да | Ключ флага |
| rolloutPercentage | number (0–100) | нет | Процент пользователей, для которых флаг включён. Sticky только для «on»: раз фича включена — не отключаем при уменьшении rollout. Для «off» пересчитываем — при увеличении rollout пользователи могут получить «on». |
| status | "active" | "disabled" | нет | Аналогично эксперименту |
Локальная симуляция (без бэкенда)
- Свой RealtimeProvider — реализуйте интерфейс
RealtimeProvider: при вызовеconnect()можно сразу вызватьonConfigUpdateс тестовым массивомConfigItem[], либо эмулировать задержку и затем отправить конфиг. - WebSocket-сервер в репозитории — в монорепе есть
demo-app-backend: поднимает WebSocket наws://localhost:3001, при подключении отправляет текущий конфиг; при получении сообщения{ type: "set_experiments", experiments: [...] }обновляет конфиг и рассылает его всем клиентам. Запуск: из корняnpm run backendилиnpm run dev(вместе с frontend и админкой). - Админка (demo-app-admin-panel) — подключается к тому же WebSocket, позволяет менять эксперименты (ключ, варианты, веса, статус, splitPercentage) и feature flags (ключ, rolloutPercentage, статус). После сохранения бэкенд рассылает обновление — демо-приложение и все открытые вкладки получают новый конфиг и пересчитывают варианты.
Стратегия логирования в коде
- ABClientError — ошибки использования API (не вызван
initializeUser, вызов послеdestroy(), невалидный ключ,useABClientвне провайдера). Разработчик видит явный throw с кодом и сообщением. - logger.error (если передан) — инфраструктурные сбои (WebSocket не подключился, ошибка в callback обновления конфига). Библиотека продолжает работать.
- logger.warn — подозрительные случаи (неизвестный ключ эксперимента/флага при уже загруженном конфиге). Помогает находить опечатки в ключах.
Рекомендации при изменении API
- Не ломать контракт
StorageAdapterиRealtimeProvider— сторонние адаптеры и провайдеры должны продолжать работать. - Новые опциональные поля в конфиге и в типах экспериментов/флагов — добавлять как опциональные, с обратной совместимостью.
- При добавлении новых кодов ошибок — дополнять
ABClientErrorCodeи описание в README.
Лицензия
ISC
