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.

1

Install

Install @avsbhq/node and @avsbhq/utils.

2

Get your SDK key

Open Environments in your project's sidebar and copy the SDK key.

3

Set up the server SDK

Create one AvsbServer when your app starts. Wait for onReady() before you handle any requests.

4

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.

5

Read a flag in a route

Read flags from ctx.avsb inside any route that runs after the middleware.

6

Track an event

Call ctx.avsb.track to record a conversion for the current user.

7

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.

8

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:

Shell
npm install @avsbhq/node@^1 @avsbhq/utils@^1
Shell1 line

Copy the SDK key into your environment file (.env):

Shell
AVSB_SDK_KEY=sdk_production_xxxxxxxxxxxxxxxx
Shell1 line

Set up the server SDK (src/avsb.ts):

TypeScript
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)  }}
TypeScript10 lines

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:

TypeScript
import type { RequestBoundClient } from '@avsbhq/utils'/** What the middleware adds to the Koa context. */export interface AvsbKoaContext {  avsb: RequestBoundClient  request: { body: unknown }  body: unknown}
TypeScript8 lines

Mount the middleware before routes (src/app.ts):

TypeScript
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())
TypeScript37 lines
Info

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.

The one mistake: ctx.avsb is not always there

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:

TypeScript
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 }})
TypeScript9 lines

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):

TypeScript
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}
TypeScript10 lines

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:

TypeScript
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 }})
TypeScript8 lines

Graceful shutdown

TypeScript
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)  })})
TypeScript12 lines

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:

TypeScript
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')
TypeScript24 lines

What's next

Was this helpful?