@avsbhq/node
v1.2.0
Published
Node.js server-side SDK for the [A vs B](https://app.avsb.cloud) platform.
Readme
@avsbhq/node
Node.js server-side SDK for the A vs B platform.
Evaluate feature flags, bucket users consistently, and track conversion events on the server. Includes Express and Fastify middleware, real-time datafile streaming over SSE, pluggable sticky bucketing (in-memory and Redis), multi-context evaluation, and per-request decision logs.
1. Install
npm install @avsbhq/nodeRequires Node.js 18 or later.
No mandatory peer dependencies. Express and Fastify middleware are included but only activated when the corresponding framework is present. Install @avsbhq/utils for additional middleware (Hono, Koa, Lambda, Cloudflare Workers), OpenFeature integration, and warehouse-native decision sinks.
2. Quickstart
import { AvsbServer } from '@avsbhq/node'
const server = new AvsbServer({
sdkKey: process.env.AVSB_SDK_KEY!,
})
const result = await server.onReady()
if (!result.success) {
process.exit(1) // or degrade gracefully: result.degraded === true means defaults are safe
}
// Evaluate per-request via forUser()
const user = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' })
const flag = user.getBoolFlag('new-checkout-flow', false)
if (flag.isEnabled()) {
renderNewCheckout()
}
user.track('checkout_started', { value: 99.0 })Instantiate AvsbServer once at app boot. The server holds the datafile in memory and refreshes it on a polling interval (or via SSE when streaming: true).
3. SDK keys
Get the SDK key for the environment you want to target from your A vs B project's Environments page. The key format is sdk_<environment>_<id> (e.g. sdk_production_clp1a2b3c4d5e6).
AVSB_SDK_KEY=sdk_production_...When used server-side, the SDK key only ever needs to leave the server to hit the datafile CDN — it should never end up in client-rendered HTML, browser bundles, or browser-accessible config. Store it in environment variables only.
Server SDK keys cannot be rotated without a process restart; the new key must be set in the environment before the server boots (or trigger a rolling restart in your orchestrator).
4. Identity
The server SDK is stateless per request — it does not hold a persistent context. Pass the user context on each call using forUser() or as a context option on individual calls.
forUser(context) / createUserContext(context)
Creates a UserBoundClient scoped to the given context. All flag and track calls on the returned client use that context automatically. Carries its own per-request decision log.
const user = server.forUser({
kind: 'user',
key: req.user.id,
plan: req.user.plan,
country: req.user.country,
})
const flag = user.getBoolFlag('checkout-v2', false)
user.track('page_viewed', { properties: { page: '/checkout' } })createUserContext is an ergonomic alias for forUser.
Per-call context
Pass context in the options object when not using forUser():
const flag = server.getBoolFlag('checkout-v2', false, {
context: { kind: 'user', key: 'u_123', plan: 'pro' },
})alias(previousContext, newContext)
Send an alias event linking two identities in the analytics pipeline. Typically called server-side when a user signs up or links an anonymous session to an account:
await server.alias(
{ kind: 'user', key: anonymousId },
{ kind: 'user', key: user.id }
)5. Multi-context
Pass a multi-context to evaluate flags against multiple context kinds simultaneously:
const user = server.forUser({
kind: 'multi',
user: { kind: 'user', key: req.user.id, plan: req.user.plan },
organization: { kind: 'organization', key: req.org.id, tier: req.org.tier },
})
const flag = user.getBoolFlag('enterprise-dashboard', false)
// The rule may target organization.tier and bucket on user.key simultaneously6. Reading flags
Typed variants (recommended)
const darkMode = user.getBoolFlag('dark-mode', false)
const theme = user.getStringFlag('theme', 'light')
const maxItems = user.getNumberFlag('max-results', 25)
const config = user.getJsonFlag<{ timeout: number }>('api-config', { timeout: 5000 })Each typed variant performs a runtime type check against the datafile's declared flag type. A mismatch logs a warning and returns the default.
Generic getFlag<T>
const flag = server.getFlag<boolean>('checkout-v2', false, {
context: { kind: 'user', key: 'u_123' },
})The Flag<T> object
flag.value // T — the variation value
flag.isEnabled() // true if source === 'rule' && value is truthy
flag.variationKey // 'on' | 'off' | 'control' | null (null = default served)
flag.exists() // false when source === 'not_found'
flag.source // 'rule' | 'sticky' | 'holdout' | 'datafileOverride' | 'default' | ...
flag.ruleId // which rule matched
flag.reasons // string[] — evaluation pathgetAllFlags(context, options?)
const all = server.getAllFlags({ kind: 'user', key: 'u_123' })
// Exposures are suppressed by default. Pass { fireExposures: true } to enable.Forced decisions (QA / testing)
// Override for a specific user context key
server.setOverrideForUser('checkout-v2', 'u_qa', 'on')
server.setOverrideForUser('checkout-v2', 'u_qa', 'on', 'user') // explicit kind
// Global override (all contexts)
server.setGlobalOverride('checkout-v2', 'on')
// Clear
server.clearOverrideForUser('checkout-v2', 'u_qa')
server.clearGlobalOverride('checkout-v2')
server.clearAllOverrides()Per-request forced decisions (bound to a UserBoundClient):
const user = server.forUser(ctx)
user.setForcedDecision('checkout-v2', { variationKey: 'on' })
user.clearForcedDecision('checkout-v2')7. Tracking events
user.track('purchase_completed', {
value: 199.0,
properties: { plan: 'annual', sku: 'PRO_ANNUAL' },
})When called directly on server (not via forUser), include context:
server.track('purchase_completed', {
value: 199.0,
context: { kind: 'user', key: 'u_123' },
properties: { plan: 'annual' },
})Events are synchronous calls that queue the event for batch flushing. There is no await on track.
8. Error handling
logger option
import { AvsbServer, createLogger } from '@avsbhq/node'
import pino from 'pino'
import { createPinoTransport } from '@avsbhq/utils/log/pino'
const server = new AvsbServer({
sdkKey: process.env.AVSB_SDK_KEY!,
logger: createLogger({
level: 'info',
transports: [createPinoTransport({ logger: pino(), level: 'info' })],
}),
})onReady() error handling
const result = await server.onReady()
// result.success: boolean
// result.source: 'network' | 'bootstrap' | 'timeout' | 'error'
// result.degraded: true means init failed but defaults are served; polling continues
if (!result.success && !result.degraded) {
throw result.error
}onReady() never rejects — it always resolves. Check result.success to decide whether to proceed.
Event bus
const unsubscribe = server.on('error', (err) => {
myLogger.error('SDK error', { error: err.message })
})
server.on('configUpdate', ({ publishedAt, reason }) => {
myLogger.info('datafile refreshed', { publishedAt, reason })
})9. SSR / hydration
Not applicable to server-side Node.js usage. For Next.js App Router SSR, see @avsbhq/next which provides getDatafile, evaluateFlagServer, and AvsbHydrator for the server-to-client bootstrap handoff.
For server-side rendering outside of Next.js, fetch the datafile via bootstrap to avoid an extra round-trip:
const server = new AvsbServer({
sdkKey: process.env.AVSB_SDK_KEY!,
bootstrap: cachedDatafile, // pre-fetched FlagDatafile object
})
// onReady() resolves immediately with source: 'bootstrap'10. Graceful shutdown
SIGTERM handler
process.on('SIGTERM', async () => {
await server.close() // abort in-flight fetches, drain events
process.exit(0)
})close() calls server.flush() internally, then aborts any open streaming connections and stops polling.
Manual flush
await server.flush() // drain queued events without teardown11. Testing
import { createMockClient, TestData } from '@avsbhq/test'
const td = TestData.flag('checkout-v2')
.booleanFlag()
.variationForUser('u_test', true)
.fallthroughVariation(false)
const mock = createMockClient({ flags: [td.build()] })
// mock has the same interface as AvsbServer; inject it in testsFor assertion helpers:
import { recordEvaluations } from '@avsbhq/test'
const recorder = recordEvaluations(mock)
callCodeUnderTest(mock)
expect(recorder).toHaveEvaluated('checkout-v2')
.withContext({ kind: 'user', key: 'u_test' })
.withValue(true)12. Framework integrations
Express
import express from 'express'
import { AvsbServer, expressMiddleware } from '@avsbhq/node'
const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })
await avsb.onReady()
const app = express()
app.use(expressMiddleware(avsb, {
contextFrom: (req) => ({
kind: 'user',
key: req.user?.id ?? 'anon',
plan: req.user?.plan,
}),
}))
app.get('/checkout', (req, res) => {
const flag = req.avsb.getBoolFlag('new-checkout-flow', false)
res.render(flag.isEnabled() ? 'checkout-v2' : 'checkout-v1')
})expressMiddleware decorates each request with req.avsb — a UserBoundClient scoped to the context returned by contextFrom. It also opens an AsyncLocalStorage scope so getRequestClient() from @avsbhq/utils works anywhere in the call chain.
Fastify
import Fastify from 'fastify'
import { AvsbServer, fastifyPlugin } from '@avsbhq/node'
const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })
await avsb.onReady()
const app = Fastify()
await app.register(fastifyPlugin, {
server: avsb,
contextFrom: (req) => ({
kind: 'user',
key: req.headers['x-user-id'] as string,
}),
})
app.get('/features', (req, reply) => {
const flag = req.avsb.getBoolFlag('new-feature', false)
reply.send({ enabled: flag.isEnabled() })
})Sticky bucketing
Keep users in the same variation across rule changes. In-memory is suitable for single-process deployments:
import { InMemoryStickyBucketService } from '@avsbhq/node'
const server = new AvsbServer({
sdkKey: process.env.AVSB_SDK_KEY!,
stickyBucketService: new InMemoryStickyBucketService(),
})Redis for multi-process and multi-region deployments:
import { createClient } from 'redis'
import { RedisStickyBucketService } from '@avsbhq/node'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
const server = new AvsbServer({
sdkKey: process.env.AVSB_SDK_KEY!,
stickyBucketService: new RedisStickyBucketService({
redis,
prefix: 'avsb:sticky:',
}),
})DynamoDB and PostgreSQL adapters are available in @avsbhq/utils.
Real-time streaming
Enable SSE streaming to receive datafile updates the instant a flag changes — no polling delay:
const server = new AvsbServer({
sdkKey: process.env.AVSB_SDK_KEY!,
streaming: true,
})Decision logs
Capture full evaluation reasons per request for debugging or audit:
import { createDecisionLog } from '@avsbhq/node'
const log = createDecisionLog()
const user = server.forUser({ kind: 'user', key: 'u_123' })
const flag = user.getBoolFlag('checkout-v2', false)
// The bound client's decision log captures all evaluations automatically:
const entries = user.getDecisionLog().entries
// [{ flagKey, value, variationKey, source, ruleId, ruleType, reasons, evaluatedAt }]13. Migration
From LaunchDarkly Node
| LaunchDarkly Node | @avsbhq/node |
|---|---|
| init(sdkKey, options) | new AvsbServer({ sdkKey }) |
| client.waitForInitialization() | await server.onReady() |
| client.variation('key', context, default) | server.forUser(ctx).getBoolFlag('key', default).value |
| client.variationDetail('key', context, default) | server.forUser(ctx).getFlag('key', default) |
| client.track('event', context, { metricValue }) | server.forUser(ctx).track('event', { value }) |
| client.identify(context) | not applicable — server is stateless; use forUser(ctx) |
| client.allFlagsState(context) | server.getAllFlags(ctx) |
| client.close() | await server.close() |
Key differences:
getFlagreturnsFlag<T>not a raw value. Access.valuefor the primitive.trackusesvalue(notmetricValue) for the numeric metric.- The server is context-free at construction; all evaluation is per-request via
forUser(). - Multi-context is native — no
ldUseradapter needed.
From Statsig Node
| Statsig Node | @avsbhq/node |
|---|---|
| Statsig.initialize(serverSecret) | new AvsbServer({ sdkKey }) |
| Statsig.checkGate(user, 'gate') | server.forUser(ctx).getBoolFlag('gate', false).isEnabled() |
| Statsig.getExperiment(user, 'exp').get('param', default) | server.forUser(ctx).getFlag('exp', default).value |
| Statsig.logEvent(user, 'event', value, metadata) | server.forUser(ctx).track('event', { value, properties: metadata }) |
| Statsig.flush() | await server.flush() |
