Bun

Bun implements both the Node.js API surface and the Web Fetch API, so either A vs B server package runs on it. Pick by process lifetime, not by taste:

PackageUse it whenWhat you get
@avsbhq/nodeA long-running server.Background polling, SSE datafile streaming, sticky bucketing, per-request decision logs, forUser.
@avsbhq/edgeA short-lived or serverless-style handler.One client per request, a process-memory datafile cache, no timers, no forUser.

Using @avsbhq/node

1

Install

Add the Node SDK with bun add.

2

Obtain your SDK key

Click Environments in your A vs B project's sidebar and copy the SDK key for the environment this process serves. Put it in .env or your deployment's environment.

3

Construct the server once

AvsbServer holds the datafile in memory and refreshes it in the background, so it belongs at module scope, not per request.

4

Survive hot reload

bun --hot re-runs module scope on every reload. Pin the instance to the global so one poll loop and one stream survive.

5

Bind the request context

server.forUser(context) returns a UserBoundClient with its own decision log. Reads on it need no further context.

6

Read a flag and track an event

Typed getters return a Flag<T>. revenue carries money and value carries a quantity.

Install the package:

Shell
bun add @avsbhq/node
Shell1 line

Click Environments in the sidebar of your A vs B project. It sits on its own there, next to Settings, not inside it. Each environment card shows a masked SDK key with Reveal and Copy buttons.

  1. Environments lives in the sidebar on its own, not inside Settings.
  2. Click Reveal to see the full key, then Copy to copy it.

Store your SDK key in .env:

Shell
AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xn
Shell1 line
Your SDK key is public
Your SDK key is a public identifier, not a secret: it is safe to ship in browser and mobile bundles, it can only fetch that environment's flag configuration and send events, and it can never read or change anything in your dashboard. Credentials covers all four A vs B credentials and which one to reach for.

Construct the server once, in its own module:

TypeScript
import { AvsbServer } from '@avsbhq/node'// `bun --hot` re-runs module scope on every reload. Pinning the instance to the// global keeps ONE server, one poll loop, and one datafile stream across// reloads; without this, each reload leaves the previous one running.const globalForAvsb = globalThis as typeof globalThis & { avsbServer?: AvsbServer }export const server: AvsbServer =  globalForAvsb.avsbServer ?? new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY ?? '' })globalForAvsb.avsbServer = serverexport async function waitForAvsb(): Promise<void> {  const init = await server.onReady()  if (!init.success) {    // Flags serve the defaults you pass until a datafile arrives, and polling    // continues in the background. Survivable, not fatal.    console.warn('avsb init failed', init.source)  }}
TypeScript20 lines

onReady() never rejects and returns the same promise on repeated calls, so awaiting it before you start listening costs one round trip in total.

Serve requests with a context bound per request:

TypeScript
// docs-example: not typechecked here, because Bun's global types ship with the// bun runtime rather than from npm, so this repo has nothing to check Bun.serve// against.import { server, waitForAvsb } from './avsb'await waitForAvsb()Bun.serve({  port: 3000,  async fetch(req: Request): Promise<Response> {    const avsb = server.forUser({      kind: 'user',      key: req.headers.get('x-user-id') ?? 'anon',      plan: req.headers.get('x-user-plan') ?? 'free',    })    return Response.json({ enabled: avsb.getBoolFlag('checkout_v2', false).isEnabled() })  },})
TypeScript19 lines

Run the dev server with hot reload:

Shell
bun --hot run server.ts
Shell1 line
Streaming and hot reload

@avsbhq/node streams datafile updates over SSE by default and falls back to polling when streaming is unavailable. Both live on the server instance, which is why the module above pins it to the global: without that, ten reloads leave ten streams and ten poll loops connected. In production you run without --hot and the question does not arise.

Reading flags

TypeScript
import type { UserBoundClient, Flag } from '@avsbhq/node'interface Decision {  enabled: boolean  theme: string  pricing: { tier: string; seats: number }  variationKey: string | null}function decide(avsb: UserBoundClient): Decision {  const checkout: Flag<boolean> = avsb.getBoolFlag('checkout_v2', false)  const theme: Flag<string> = avsb.getStringFlag('ui_theme', 'default')  const pricing: Flag<{ tier: string; seats: number }> = avsb.getJsonFlag('pricing_config', {    tier: 'standard',    seats: 1,  })  return {    enabled: checkout.isEnabled(),    theme: theme.value,    pricing: pricing.value,    variationKey: checkout.variationKey,  }}
TypeScript24 lines

flag.source is one of datafileOverride, runtimeOverride, sticky, rule, holdout, bandit, default, disabled, not_found, or not_ready. isEnabled() is true only when a real decision produced a truthy value, so it is always false for default, disabled, not_found, and not_ready. exists() answers "is this key in the datafile", and is false for both not_found and not_ready, because with no datafile the question is unanswerable.

Typed getters check the value against the type the dashboard declares for the flag. On a mismatch they log a warning naming both types and return your default; they never throw and never coerce.

Tracking events

TypeScript
import type { UserBoundClient } from '@avsbhq/node'async function recordConversions(avsb: UserBoundClient): Promise<void> {  avsb.track('purchase_completed', { revenue: 99.0 }) // money, decimal major units  avsb.track('items_added', { value: 3 }) // a quantity  avsb.track('signup_completed') // a count  await avsb.trackPurchase({    orderId: 'order_1001',    total: 99.0,    currency: 'USD',    items: [{ sku: 'PRO_ANNUAL', price: 99.0, quantity: 1 }],  })}
TypeScript14 lines

revenue and value are two separate columns end to end, so one conversion can carry money, a quantity, or both. properties is accepted by the shared type but is not stored for conversions: the ingestion contract for a metric event keeps the event name, the two numbers, the timestamp, and the visitor id. The SDK warns once and drops them rather than pretending. Exposure events do carry properties.

track is synchronous. Events and purchases queued before the datafile arrives are sent once it does.

Multi-context identity

TypeScript
import type { AvsbServer, UserBoundClient } from '@avsbhq/node'function bindAccount(server: AvsbServer, userId: string, orgId: string): UserBoundClient {  return server.forUser({    kind: 'multi',    user: { kind: 'user', key: userId, plan: 'pro' },    organization: { kind: 'organization', key: orgId, tier: 'enterprise' },  })}
TypeScript9 lines

A rule can target organization.tier while bucketing on user.key.

Graceful shutdown

Bun propagates SIGTERM. close() aborts in-flight fetches, disconnects the stream, stops polling, and drains the event queue:

TypeScript
import type { AvsbServer } from '@avsbhq/node'function installShutdownHandler(server: AvsbServer): void {  process.on('SIGTERM', () => {    void server.close().then(() => process.exit(0))  })}
TypeScript7 lines

Use await server.flush() on its own to drain queued events without tearing the server down.

Using @avsbhq/edge

For a short-lived or serverless-style handler, @avsbhq/edge has no background timers and builds one client per request. Its Bun adapter caches the datafile in a process-memory map shared by every request:

TypeScript
// docs-example: not typechecked here, because Bun's global types ship with the// bun runtime rather than from npm, so this repo has nothing to check Bun.serve// and Bun.env against.import { createBunHandler } from '@avsbhq/edge/bun'Bun.serve({  port: 3000,  fetch: createBunHandler({    sdkKey: Bun.env.AVSB_SDK_KEY ?? '',    handler: async (req, client) => {      const banner = client.getBoolFlag('new_banner', false)      return Response.json({ enabled: banner.isEnabled() })    },  }),})
TypeScript15 lines

Four things differ from the server SDK, and all four are deliberate:

  • There is no forUser. The adapter binds the visitor context for you from the x-avsb-visitor-id header, then the A vs B snippet's _avsb_visitor cookie, so client.getBoolFlag('key', false) needs no context argument. Bun exposes no geolocation, so the default context carries none rather than inventing empty strings. Pass contextFrom to add your own, building on the exported bunContextFrom.
  • There is no onReady. The adapter awaits init() before your handler runs. A read before that returns your default with source: 'not_ready' and warns once per key.
  • The flush is client.flushEvents(), and the adapter starts it without blocking, because Bun's event loop outlives the response.
  • There is no trackPurchase. The only tracking method is track(eventKey, payload). payload.revenue and payload.value each land in their own column, same as the server SDK. payload.properties is dropped either way. For full purchase tracking (line items, tax, and so on), use @avsbhq/node on a long-running server instead.

memoryRetentionMs (default 1 hour) decides how long the process cache keeps a datafile at all. Whether a stored copy is fresh enough to serve is the client's cacheTtlMs (default 5 minutes), after which it revalidates over the network and, if that fails, serves the stale copy with degraded: true.

Testing

Bun ships its own test runner with Jest-compatible matchers. createMockServer mirrors the server SDK, forUser included, and its bound client has the same read surface as the client an edge handler receives:

TypeScript
import { createMockServer, flagsFromTestData, TestData } from '@avsbhq/test'import { describe, it, expect } from 'bun:test'const server = createMockServer(  flagsFromTestData([    TestData.flag('checkout_v2')      .booleanFlag()      .variationForUser('u_pro', true)      .fallthroughVariation(false)      .build(),  ]),)describe('checkout decision', () => {  it('serves v2 to the pro user', () => {    const avsb = server.forUser({ kind: 'user', key: 'u_pro' })    expect(avsb.getBoolFlag('checkout_v2', false).value).toBe(true)  })  it('serves the fallthrough to everyone else', () => {    const avsb = server.forUser({ kind: 'user', key: 'u_other' })    expect(avsb.getBoolFlag('checkout_v2', false).value).toBe(false)  })  it('logs one decision per bound client', () => {    const avsb = server.forUser({ kind: 'user', key: 'u_pro' })    avsb.getBoolFlag('checkout_v2', false)    // Each forUser() carries its own decision log, so this count is isolated    // from the other tests in the file.    expect(avsb.getDecisionLog().entries).toHaveLength(1)  })})
TypeScript32 lines

What's next

Was this helpful?