Cloudflare Workers

Cloudflare Workers run in V8 isolates distributed across Cloudflare's global network. This guide wires @avsbhq/edge into a Worker so evaluation happens locally, the datafile is cached in Workers KV across cold starts, and queued events leave the isolate after the response has already gone out.

1

Install

Add the edge client. It has no peer dependencies: the Cloudflare surfaces it needs are declared structurally inside the package, so you never install @cloudflare/workers-types just to use it.

2

Obtain your SDK key

Open your A vs B project, select Environments in the sidebar, and copy the SDK key for the environment this Worker serves. Keys are shaped sdk_<environment>_<id>.

3

Declare a KV namespace

Create a KV namespace so a new isolate reads the datafile from KV instead of fetching it from the CDN on every cold start.

4

Bootstrap with createCloudflareHandler

The @avsbhq/edge/cloudflare subpath exports a factory that builds the client, binds the visitor context, awaits init, and flushes through ctx.waitUntil after your handler returns. Because Cloudflare exposes secrets and bindings only on the env argument of fetch, sdkKey and kv also accept a function of env.

5

Read a flag

Inside your handler the client is already initialised and already bound to the visitor, so client.getBoolFlag('key', false) needs no context argument.

6

Track an event

Call client.track(eventKey, payload). The factory flushes the queue for you.

7

Adjust the identity if you need to

The adapter builds the evaluation context from the request by itself. Supply your own contextFrom only when you have an identity Cloudflare does not, such as a signed-in account id.

Install the edge client:

Shell
npm install @avsbhq/edge
Shell1 line

Put the SDK key in your Worker configuration. It is environment-specific, so a staging Worker and a production Worker carry different values:

TOML
name = "my-worker"main = "src/worker.ts"compatibility_date = "2024-09-23"[vars]AVSB_SDK_KEY = "sdk_production_ttqm0eaj4vth1krcb2xn"[[kv_namespaces]]binding = "AVSB_KV"id = "<your-kv-namespace-id>"preview_id = "<your-preview-kv-namespace-id>"
TOML11 lines
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.

If you would rather keep the value out of the repository, npx wrangler secret put AVSB_SDK_KEY works too and the code below is unchanged: both a [vars] entry and a secret arrive on env.

Bootstrap the client

TypeScript
import { createCloudflareHandler } from '@avsbhq/edge/cloudflare'import type { KVNamespace } from '@avsbhq/edge/cloudflare'interface Env {  AVSB_SDK_KEY: string  AVSB_KV: KVNamespace}export default {  fetch: createCloudflareHandler<Env>({    sdkKey: (env) => env.AVSB_SDK_KEY,    kv: (env) => env.AVSB_KV,    handler: async (req, client) => {      const homepage = client.getBoolFlag('new_homepage', false)      return Response.json({ enabled: homepage.isEnabled() })    },  }),}
TypeScript18 lines

That is the whole wiring. There is no module-scope client to memoise and no readiness check to write: the factory constructs one client per request, awaits init() before your handler runs, and calls ctx.waitUntil(client.flushEvents()) after it returns.

Freshness and retention are two different clocks

cacheTtlMs (default 5 minutes) decides how long a cached datafile counts as fresh. Past it the client revalidates over the network, and if that fails it serves the stale copy with degraded: true rather than nothing. kvRetentionSeconds (default 24 hours, minimum 60) decides how long KV keeps the entry at all, which is garbage collection. Keeping them apart is what makes the degraded path reachable.

What happens on a cache miss

On the first request after a deploy, or after the KV entry is collected, the client fetches the datafile from the A vs B CDN and writes it back to KV for the isolates that follow. If that fetch fails with nothing cached, init() resolves { success: false }, every read returns the default you passed with source: 'not_ready', and the SDK logs why. Your Worker still answers.

Reading flags

Inside the handler the client is bound to the request, so the context argument is optional:

TypeScript
import type { AvsbEdgeClient, EvaluationSource, Flag } from '@avsbhq/edge'interface HomepageDecision {  enabled: boolean  variationKey: string | null  source: EvaluationSource}function decideHomepage(client: AvsbEdgeClient): HomepageDecision {  const flag: Flag<boolean> = client.getBoolFlag('new_homepage', false)  return { enabled: flag.isEnabled(), variationKey: flag.variationKey, source: flag.source }}
TypeScript12 lines

The full set of typed getters, each returning a frozen Flag<T>:

TypeScript
import type { AvsbEdgeClient, EvalContext, Flag } from '@avsbhq/edge'function readEverything(client: AvsbEdgeClient, other: EvalContext): void {  const bool: Flag<boolean> = client.getBoolFlag('new_homepage', false)  const copy: Flag<string> = client.getStringFlag('cta_copy', 'Buy now')  const size: Flag<number> = client.getNumberFlag('page_size', 25)  const config: Flag<{ timeout: number }> = client.getJsonFlag('api_config', { timeout: 5000 })  // Pass a context to evaluate one call against a different identity without  // changing the one the adapter bound.  const forSomeoneElse: Flag<boolean> = client.getBoolFlag('new_homepage', false, other)  // Bulk read. Fires no exposures by default, because a bulk read is not a  // decision served to a visitor.  const all: Record<string, Flag> = client.getAllFlags()}
TypeScript16 lines

A typed getter checks the type the dashboard declares for the flag. On a mismatch it returns your default with source: 'not_found' and logs one line naming both types. It never coerces and never throws.

flag.source says where the value came from:

sourceMeaning
ruleA targeting rule or experiment matched.
holdoutThis visitor is held out of the flag.
banditA bandit rule chose the action.
datafileOverrideAn operator pinned this visitor in the dashboard.
runtimeOverrideYour code pinned it at runtime.
stickyA stored assignment was reused.
defaultNothing matched, the flag's default variation was served.
disabledThe flag is turned off in this environment.
not_foundThe datafile loaded and this key is not in it.
not_readyinit() had not finished, so your defaultValue came back.

isEnabled() is true only for a real decision with a truthy value, so it is always false for default, disabled, not_found, and not_ready.

Tracking events

TypeScript
import type { AvsbEdgeClient } from '@avsbhq/edge'function recordConversion(client: AvsbEdgeClient, orderTotal: number): void {  client.track('signup_completed')  client.track('checkout_completed', { revenue: orderTotal })}
TypeScript6 lines

payload.revenue is money for this conversion and lands in the wire's revenue column. payload.value is a separate number, for a quantity like items or seats, and lands in its own value column: the two never mix. payload.properties is accepted so the call reads the same across runtimes, but it is not stored on tracked events, and the SDK warns once, naming revenue and value as the two places that are actually stored and reportable. Exposure events do carry properties, and exposures are automatic: reading a flag whose decision came from an experiment queues one.

The factory flushes for you. If you build AvsbEdgeClient by hand, call ctx.waitUntil(client.flushEvents()) at the end of every request.

Identity

You do not have to write a context. The adapter resolves the visitor from the x-avsb-visitor-id header, then the A vs B snippet's _avsb_visitor cookie, so a site running both Web Experiments and this Worker reports one visitor and both surfaces join in results. It also reads Cloudflare's own geolocation (country, region, city, continent, postal code, timezone, latitude, longitude) plus colo and asn, falling back to the cf-ipcountry header when request.cf is absent.

The client IP is deliberately never used as the bucketing key: that key is written to visitorId on every exposure row.

Supply contextFrom when you have an identity Cloudflare does not, and build on the exported helper so you keep the platform data:

TypeScript
import { createCloudflareHandler, cloudflareContextFrom } from '@avsbhq/edge/cloudflare'export default {  fetch: createCloudflareHandler<Env>({    sdkKey: (env) => env.AVSB_SDK_KEY,    kv: (env) => env.AVSB_KV,    contextFrom: (req) => ({      ...cloudflareContextFrom(req),      key: req.headers.get('x-user-id') ?? 'anon',      plan: req.headers.get('x-account-plan') ?? 'free',    }),    handler: async (req, client) => Response.json({ ready: client.isReady() }),  }),}
TypeScript14 lines

Return { kind: 'multi', ... } from contextFrom to target on more than one identity at once. There is no identify() at the edge: the context is fixed for the life of one request.

Keeping the datafile fresh

A published change reaches your Workers within the 5 minute freshness window on its own. To make it immediate, open Organization Settings, go to the Integrations tab, and register a webhook for the flag.published event. Have the receiver refresh the KV entry the adapter reads.

The webhook body carries the project, environment, and who published, not the datafile itself, so the receiver fetches the datafile and writes it under the key the KV adapter looks for:

TypeScript
import { fetchDatafileFromCdn } from '@avsbhq/edge'import type { KVNamespace } from '@avsbhq/edge/cloudflare'interface WebhookEnv {  AVSB_SDK_KEY: string  AVSB_KV: KVNamespace}export default {  async fetch(request: Request, env: WebhookEnv): Promise<Response> {    // Verify the delivery signature before acting on it. See the Webhooks guide.    if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 })    const outcome = await fetchDatafileFromCdn(env.AVSB_SDK_KEY)    if (outcome.kind !== 'datafile') {      return new Response('datafile unavailable', { status: 502 })    }    await env.AVSB_KV.put(      `avsb:df:${env.AVSB_SDK_KEY}`,      JSON.stringify({ datafile: outcome.datafile, cachedAt: Date.now() }),      { expirationTtl: 86_400 },    )    return new Response('ok')  },}
TypeScript26 lines

fetchDatafileFromCdn returns a structured outcome (datafile, httpError, or transportError) rather than a nullable value, so a receiver can log the status and the URL it tried.

Graceful shutdown

Workers have no shutdown lifecycle: isolates are discarded silently. createCloudflareHandler already calls ctx.waitUntil(client.flushEvents()) after your handler returns, so queued exposures, tracked events, the deferred KV write, and the heartbeat all complete after the response has been delivered. There is nothing to close: an edge client lives for one request and holds no timers, sockets, or global state.

Testing

@avsbhq/test gives you a server-shaped mock whose bound client has the same read surface as the client your handler receives, so the decision logic can be tested with no network and no isolate:

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

To exercise the real evaluator instead of a mock, construct AvsbEdgeClient with a datafile bootstrap and logLevel: 'silent': init() then performs no network call at all.

What's next

Was this helpful?