/Docs

Koa integration

This guide assumes a Koa 2 server running on Node.js 18+. By the end you'll have per-request flag evaluation available via ctx.state.avsb on every route, using @avsbhq/node and the @avsbhq/utils/middleware/koa adapter.

1

Install

bash
1npm install @avsbhq/node@^1 @avsbhq/utils@^1
2

Obtain your SDK key

Open Settings > Environments in your A vs B project and copy the server-side SDK key:

.env
bash
1AVSB_SDK_KEY=sdk_production_xxxxxxxxxxxxxxxx
3

Initialise the server SDK

src/avsb.ts
ts
1import { AvsbServer } from '@avsbhq/node'
2
3export const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })
4
5export async function waitForAvsb(): Promise<void> {
6 const result = await avsb.onReady()
7 if (!result.success && !result.degraded) {
8 console.warn('[avsb] degraded init', result.error)
9 }
10}
4

Mount the middleware

The koaMiddleware from @avsbhq/utils/middleware/koa opens an AsyncLocalStorage scope and stores the UserBoundClient on ctx.state.avsb.

src/app.ts
ts
1import Koa from 'koa'
2import Router from '@koa/router'
3import bodyParser from '@koa/bodyparser'
4import { koaMiddleware } from '@avsbhq/utils/middleware/koa'
5import { avsb } from './avsb'
6
7export const app = new Koa()
8const router = new Router()
9
10app.use(bodyParser())
11
12// Mount before routes.
13app.use(
14 koaMiddleware(avsb, {
15 contextFrom: (ctx) => {
16 const uid = ctx.headers['x-user-id'] as string | undefined
17 if (!uid) return undefined
18 return { kind: 'user', key: uid }
19 },
20 withDecisionLog: true,
21 })
22)
23
24// Routes defined below the middleware have access to ctx.state.avsb.
25router.post('/checkout/session', (ctx) => {
26 const checkoutV2 = ctx.state.avsb.getBoolFlag('checkout-v2', false)
27 ctx.body = {
28 flow: checkoutV2.value ? 'v2' : 'legacy',
29 variationKey: checkoutV2.variationKey ?? null,
30 }
31})
32
33app.use(router.routes())
Info
koaMiddleware is also re-exported from @avsbhq/node for projects that prefer a single dependency.
5

Track an event

ts
1router.post('/purchase', async (ctx) => {
2 const { amount } = ctx.request.body as { amount: number }
3
4 ctx.state.avsb.track('purchase', {
5 value: amount,
6 properties: { router: 'koa' },
7 })
8
9 ctx.body = { success: true }
10})
6

Use AsyncLocalStorage for implicit context

Service functions that run inside the request lifecycle can call getRequestClient without needing access to ctx:

src/services/inventoryService.ts
ts
1import { getRequestClient } from '@avsbhq/utils'
2
3export function shouldShowLowStockBanner(): boolean {
4 const client = getRequestClient()
5 const lowStockFlag = client.getBoolFlag('low-stock-banner', false)
6 return lowStockFlag.value
7}
7

Identify a user

Override the context mid-request by calling avsb.forUser(ctx) with a more specific context, then storing it on ctx.state for downstream handlers:

ts
1router.post('/login', async (ctx) => {
2 const { userId, plan } = ctx.request.body as { userId: string; plan: string }
3
4 // Replace the anonymous context with the authenticated user.
5 ctx.state.avsb = avsb.forUser({ kind: 'user', key: userId, plan })
6
7 ctx.body = { success: true }
8})

Graceful shutdown

ts
1import { app } from './app'
2import { avsb, waitForAvsb } from './avsb'
3
4await waitForAvsb()
5const server = app.listen(3000)
6
7process.on('SIGTERM', async () => {
8 server.close(async () => {
9 await avsb.close()
10 process.exit(0)
11 })
12})

Testing

ts
1import { createMockClient, TestData } from '@avsbhq/test'
2import supertest from 'supertest'
3import { app } from './app'
4
5const td = TestData.flag('checkout-v2').booleanFlag().fallthroughVariation(true)
6const mockAvsb = createMockClient({ flags: [td.build()] })
7
8// In test setup, replace the real server reference used by the middleware.
9const res = await supertest(app.callback())
10 .post('/checkout/session')
11 .set('x-user-id', 'u_test')
12
13expect(res.body.flow).toBe('v2')

What's next