Express
This guide assumes an Express 4 or 5 server running on Node.js 18+. By the end you'll have per-request flag evaluation scoped to each user's context using @avsbhq/node and the @avsbhq/utils/middleware/express adapter.
Install
Install @avsbhq/node and @avsbhq/utils.
Obtain your SDK key
Open your A vs B project and select Environments in the sidebar, then copy the SDK key. Add it to your environment configuration.
Initialise the server SDK
Create a singleton AvsbServer instance at application startup. Call onReady() before the server starts accepting traffic so the first request always has the latest datafile.
Mount the middleware
The expressMiddleware from @avsbhq/utils/middleware/express opens an AsyncLocalStorage scope per request. It decorates req.avsb with a UserBoundClient scoped to the current user's context.
Read a flag in a route handler
Access req.avsb to read flags for the current user. The client is already bound to the request context, no need to pass the user identity again.
Track an event
Call req.avsb.track to record conversion events for the current user.
Use AsyncLocalStorage for implicit context
For utilities or services that are called from inside a request handler but do not have access to req, use getRequestClient from @avsbhq/utils/middleware/express. It reads the current context from the AsyncLocalStorage scope.
Install the packages:
npm install @avsbhq/node@^1 @avsbhq/utils@^1Add the SDK key to your environment configuration (.env):
AVSB_SDK_KEY=sdk_production_xxxxxxxxxxxxxxxxKeep the key in an environment variable rather than in source, the same as any other configuration: hardcoding it makes changing environments a code change.
- Environments lives in the sidebar on its own, not inside Settings.
- Click Reveal to see the full key, then Copy to copy it.
Initialise the server SDK (src/avsb.ts):
import { AvsbServer } from '@avsbhq/node'export const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY!, // Optional: stream real-time datafile updates. // streaming: true,})export async function waitForAvsb(): Promise<void> { const result = await avsb.onReady() if (!result.success && !result.degraded) { console.warn('[avsb] SDK init failed: serving defaults', result.error) }}Mount the middleware early so all routes can access req.avsb (src/server.ts):
import express from 'express'import { expressMiddleware } from '@avsbhq/utils/middleware/express'import { avsb, waitForAvsb } from './avsb'const app = express()app.use(express.json())// Mount the A vs B middleware early so all routes can access req.avsb.app.use( expressMiddleware(avsb, { contextFrom: (req) => { // Build the EvalContext from request data. // Return undefined to skip flag evaluation for this request. // `contextFrom` receives the raw request, so narrow what you read off it. const headers = req.headers as Record<string, string | undefined> const uid = headers['x-user-id'] if (!uid) return undefined return { kind: 'user', key: uid } }, withDecisionLog: true, }))async function start() { await waitForAvsb() app.listen(3000, () => console.log('Server running on :3000'))}start()expressMiddleware lives in @avsbhq/utils/middleware/express and is re-exported from @avsbhq/node, so either import works and both are the same function. There is one signature: expressMiddleware(server, { contextFrom, withDecisionLog? }). Return undefined from contextFrom to skip AvsB for a request.
When contextFrom returns undefined, the middleware calls next() immediately and never sets req.avsb at all. It is not a client with no context: the property is simply missing. A route handler that skips the check and calls req.avsb.getBoolFlag(...) throws Cannot read properties of undefined. The type augmentation below declares avsb as always present, so TypeScript won't catch this either. Guard for it: if (!req.avsb) return next(), or default the flow when contextFrom has nothing to key on (no session cookie, a health check route, and so on).
Read a flag in a route handler (src/routes/checkout.ts):
import { Router } from 'express'import type { RequestBoundClient } from '@avsbhq/utils'const router = Router()/** What the middleware adds to every request it handles. */interface AvsbRequest { avsb: RequestBoundClient body: unknown}interface JsonResponse { json(body: unknown): unknown}router.post('/checkout', (req: AvsbRequest, res: JsonResponse) => { const checkoutV2 = req.avsb.getBoolFlag('checkout_v2', false) if (checkoutV2.value) { return res.json({ flow: 'v2', variationKey: checkoutV2.variationKey }) } return res.json({ flow: 'legacy' })})export default routerTrack an event:
router.post('/purchase', (req: AvsbRequest, res: JsonResponse) => { const { amount } = req.body as { amount: number } // `revenue` is money in major units; `value` is the separate numeric-metric // column (items in a cart, seats on a plan). req.avsb.track('purchase', { revenue: amount }) res.json({ success: true })})Use AsyncLocalStorage for implicit context: service functions without access to req can read the current context via getRequestClient (src/services/paymentService.ts):
import { getRequestClient } from '@avsbhq/utils'import { runSplitPayment, runStandardPayment } from './payments'export async function processPayment(amount: number) { // No req argument needed: context flows via AsyncLocalStorage. // It returns null outside a request scope, so handle that branch. const client = getRequestClient() if (!client) return runStandardPayment(amount) const splitPayFlag = client.getBoolFlag('split_payment', false) if (splitPayFlag.value) { return runSplitPayment(amount) } return runStandardPayment(amount)}Graceful shutdown
Call avsb.close() in your SIGTERM handler to flush any pending events before the process exits:
// Keep the reference `app.listen()` returns at startup.const httpServer = app.listen(3000)process.on('SIGTERM', () => { httpServer.close(async () => { await avsb.close() process.exit(0) })})TypeScript type augmentation
Declare req.avsb once in your own project (src/types/express.d.ts). There is no import to add: your project owns the augmentation, so it never fights another package's copy.
import type { UserBoundClient } from '@avsbhq/node'declare global { namespace Express { interface Request { avsb: UserBoundClient } }}Testing
import { createMockServer, flagsFromTestData, TestData } from '@avsbhq/test'import request from 'supertest'import { app } from './server'const td = TestData.flag('checkout_v2').booleanFlag().fallthroughVariation(true)const mockServer = createMockServer(flagsFromTestData([td.build()]))// Inject the mock into the test server by overriding the middleware.app.request.avsb = mockServer.forUser({ kind: 'user', key: 'test-user' })const res = await request(app).post('/checkout').send({})expect(res.body.flow).toBe('v2')The mock has the whole AvsbServer surface, so code that calls overrides, alias, refreshDatafile, server.utils, server.datasets or server.recs runs under it unchanged. Datasets and recommendations answer from two more options:
const mockServer = createMockServer({ flags: { checkout_v2: true }, datasets: { prices: { sku_1: { amount: 1999 } } }, // dataset slug, then key, then value recs: { similar: [{ id: 'p1' }, { id: 'p2' }] }, // recipe, then the products it returns})What's next
- Multi-context identity
- Decision logging: stream per-request decisions to a warehouse sink.
@avsbhq/nodeon npm: the package README, with the fullAvsbServerAPI.