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:
| Package | Use it when | What you get |
|---|---|---|
@avsbhq/node | A long-running server. | Background polling, SSE datafile streaming, sticky bucketing, per-request decision logs, forUser. |
@avsbhq/edge | A short-lived or serverless-style handler. | One client per request, a process-memory datafile cache, no timers, no forUser. |
Using @avsbhq/node
Install
Add the Node SDK with bun add.
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.
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.
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.
Bind the request context
server.forUser(context) returns a UserBoundClient with its own decision log. Reads on it need no further context.
Read a flag and track an event
Typed getters return a Flag<T>. revenue carries money and value carries a quantity.
Install the package:
bun add @avsbhq/nodeClick 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.
- Environments lives in the sidebar on its own, not inside Settings.
- Click Reveal to see the full key, then Copy to copy it.
Store your SDK key in .env:
AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xnConstruct the server once, in its own module:
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) }}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:
// 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() }) },})Run the dev server with hot reload:
bun --hot run server.ts@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
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, }}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
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 }], })}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
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' }, })}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:
import type { AvsbServer } from '@avsbhq/node'function installShutdownHandler(server: AvsbServer): void { process.on('SIGTERM', () => { void server.close().then(() => process.exit(0)) })}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:
// 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() }) }, }),})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 thex-avsb-visitor-idheader, then the A vs B snippet's_avsb_visitorcookie, soclient.getBoolFlag('key', false)needs no context argument. Bun exposes no geolocation, so the default context carries none rather than inventing empty strings. PasscontextFromto add your own, building on the exportedbunContextFrom. - There is no
onReady. The adapter awaitsinit()before your handler runs. A read before that returns your default withsource: '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 istrack(eventKey, payload).payload.revenueandpayload.valueeach land in their own column, same as the server SDK.payload.propertiesis dropped either way. For full purchase tracking (line items, tax, and so on), use@avsbhq/nodeon 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:
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) })})What's next
@avsbhq/nodeon npm: the package README, with the fullAvsbServerAPI, including decision logging and sticky bucketing.- Edge SDK reference: the full
AvsbEdgeClientAPI. - Sticky bucketing: keep a visitor in the same variation across rule changes.
- AWS Lambda integration: the same pattern for short-lived function invocations.