Koa
This guide is for a Koa 2 server on Node.js 18 or later. A feature flag is a setting in your code you can turn on, off, or change without a new deploy. By the end, any route can read one from ctx.avsb. You get this by installing two packages, @avsbhq/node and the @avsbhq/utils/middleware/koa adapter.
Install
Install @avsbhq/node and @avsbhq/utils.
Get your SDK key
Open Environments in your project's sidebar and copy the SDK key.
Set up the server SDK
Create one AvsbServer when your app starts. Wait for onReady() before you handle any requests.
Mount the middleware
Register koaMiddleware before your routes. For any request where it can identify the visitor, it adds a ready-to-use client at ctx.avsb.
Read a flag in a route
Read flags from ctx.avsb inside any route that runs after the middleware.
Track an event
Call ctx.avsb.track to record a conversion for the current user.
Share a client without passing it around
Deep in your code, call getRequestClient to get the current user's client, without passing ctx down through every function. Koa has no built-in way to do this, so the middleware uses a Node.js feature called AsyncLocalStorage to make it work.
Identify a user mid-request
Once you know who the visitor really is, for example right after login, call avsb.forUser(...) again with the fuller context. Store the result back on ctx.avsb for the handlers after it.
Install the packages:
npm install @avsbhq/node@^1 @avsbhq/utils@^1Copy the SDK key into your environment file (.env):
AVSB_SDK_KEY=sdk_production_xxxxxxxxxxxxxxxxSet up the server SDK (src/avsb.ts):
import { AvsbServer } from '@avsbhq/node'export const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })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) }}Declare the type the middleware adds to the Koa context, once, in your own project (src/types/koa.ts). Koa's own context type is deliberately loose (ctx: any in most examples you will find), so a small interface here is what makes ctx.avsb autocomplete everywhere below:
import type { RequestBoundClient } from '@avsbhq/utils'/** What the middleware adds to the Koa context. */export interface AvsbKoaContext { avsb: RequestBoundClient request: { body: unknown } body: unknown}Mount the middleware before routes (src/app.ts):
import Koa from 'koa'import Router from '@koa/router'import bodyParser from '@koa/bodyparser'import { koaMiddleware } from '@avsbhq/utils/middleware/koa'import { avsb } from './avsb'export const app = new Koa()const router = new Router()app.use(bodyParser())// Mount before routes.app.use( koaMiddleware(avsb, { contextFrom: (ctx) => { // `contextFrom` receives the raw Koa context, so narrow what you read. // Return undefined to skip AvsB for a request: a health check, an // asset route, anything with no visitor. const headers = ctx.headers as Record<string, string | undefined> const uid = headers['x-user-id'] if (!uid) return undefined return { kind: 'user', key: uid } }, withDecisionLog: true, }))// Routes defined below the middleware have access to ctx.avsb.router.post('/checkout/session', (ctx: AvsbKoaContext) => { const checkoutV2 = ctx.avsb.getBoolFlag('checkout_v2', false) ctx.body = { flow: checkoutV2.value ? 'v2' : 'legacy', variationKey: checkoutV2.variationKey ?? null, }})app.use(router.routes())koaMiddleware lives only in @avsbhq/utils/middleware/koa. The Node SDK re-exports Express and Fastify for convenience, but not Koa, Hono, or the other framework adapters. @avsbhq/utils is the one place to import koaMiddleware from.
When contextFrom returns undefined, the middleware calls next() immediately and never sets ctx.avsb at all. It is not a client with no context: the property is simply missing. A route that skips the check and calls ctx.avsb.getBoolFlag(...) throws Cannot read properties of undefined. Guard for it: if (!ctx.avsb) { ctx.status = 204; return }, or default the flow when contextFrom has nothing to key on (no session cookie, a health check route, and so on).
Track an event:
router.post('/purchase', async (ctx: AvsbKoaContext) => { const { amount } = ctx.request.body as { amount: number } // `revenue` is money in major units; `value` is the separate // numeric-metric column. ctx.avsb.track('purchase', { revenue: amount }) ctx.body = { success: true }})Share a client without passing it around: service code that runs inside the request but never sees ctx can still read the current user's client, using getRequestClient (src/services/inventoryService.ts):
import { getRequestClient } from '@avsbhq/utils'export function shouldShowLowStockBanner(): boolean { // Returns null outside a request scope, so handle that branch. const client = getRequestClient() if (!client) return false const lowStockFlag = client.getBoolFlag('low_stock_banner', false) return lowStockFlag.value}Identify a user mid-request: once login tells you who the visitor is, call avsb.forUser(...) again with the fuller context. Store the result back on ctx.avsb for the handlers after it:
router.post('/login', async (ctx: AvsbKoaContext) => { const { userId, plan } = ctx.request.body as { userId: string; plan: string } // Replace the anonymous context with the authenticated user. ctx.avsb = avsb.forUser({ kind: 'user', key: userId, plan }) ctx.body = { success: true }})Graceful shutdown
import { app } from './app'import { avsb, waitForAvsb } from './avsb'await waitForAvsb()const server = app.listen(3000)process.on('SIGTERM', async () => { server.close(async () => { await avsb.close() process.exit(0) })})Testing
Build a small test app with a mock server wired directly into the middleware. Swapping the real avsb singleton for a mock only works if the mock is the one actually passed to koaMiddleware, not created and left unused:
import Koa from 'koa'import Router from '@koa/router'import supertest from 'supertest'import { createMockServer, flagsFromTestData, TestData } from '@avsbhq/test'import { koaMiddleware } from '@avsbhq/utils/middleware/koa'const td = TestData.flag('checkout_v2').booleanFlag().fallthroughVariation(true)const mockAvsb = createMockServer(flagsFromTestData([td.build()]))// Wire the mock into the middleware itself, the same way `src/app.ts` wires// the real `avsb` singleton.const app = new Koa()const router = new Router()router.post('/checkout/session', (ctx: AvsbKoaContext) => { const checkoutV2 = ctx.avsb.getBoolFlag('checkout_v2', false) ctx.body = { flow: checkoutV2.value ? 'v2' : 'legacy' }})app.use(koaMiddleware(mockAvsb, { contextFrom: () => ({ kind: 'user', key: 'test-user' }) }))app.use(router.routes())const res = await supertest(app.callback()).post('/checkout/session')expect(res.body.flow).toBe('v2')What's next
- Multi-context identity
- Decision logging: stream per-request decisions to a warehouse sink.