Edge SDK reference

@avsbhq/edge is a feature-flag client built for edge runtimes: Cloudflare Workers, Vercel Edge Functions, Fastly Compute, Netlify Edge, Deno, Bun, and Lambda@Edge. It evaluates flags locally from a cached datafile and queues events for a flush that each adapter runs the way its runtime allows.

Edge and Node are different clients

The edge client lives for one request: no background polling, no streaming, no global mutable state, and nothing to close. If you want background datafile refresh, sticky-bucket persistence, or a decision log, use @avsbhq/node.

Install

Shell
npm install @avsbhq/edge
Shell1 line

Quick start

Use the adapter for your runtime. It builds the client, binds a context from the request, awaits init(), and flushes events for you:

TypeScript
import { createCloudflareHandler } from '@avsbhq/edge/cloudflare'import type { KVNamespace } from '@avsbhq/edge/cloudflare'interface Env {  AVSB_SDK_KEY: string  AVSB_DATAFILE_CACHE: KVNamespace}export default {  fetch: createCloudflareHandler<Env>({    sdkKey: (env) => env.AVSB_SDK_KEY,    kv: (env) => env.AVSB_DATAFILE_CACHE,    handler: async (request, client) => {      const flag = client.getBoolFlag('new_homepage', false)      return Response.json({ enabled: flag.isEnabled(), url: request.url })    },  }),}
TypeScript18 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.

The adapter already bound a context, so a read takes just a key and a default. Pass a context explicitly only when one call needs a different identity.

Reading flags

TypeScript
import type { AvsbEdgeClient, EvalContext, Flag, GetFlagOptions } from '@avsbhq/edge'declare const client: AvsbEdgeClientinterface EdgeReadSurface {  getFlag<T>(flagKey: string, defaultValue: T, context?: EvalContext, options?: GetFlagOptions): Flag<T>  getBoolFlag(flagKey: string, defaultValue: boolean, context?: EvalContext, options?: GetFlagOptions): Flag<boolean>  getStringFlag(flagKey: string, defaultValue: string, context?: EvalContext, options?: GetFlagOptions): Flag<string>  getNumberFlag(flagKey: string, defaultValue: number, context?: EvalContext, options?: GetFlagOptions): Flag<number>  getJsonFlag<T>(flagKey: string, defaultValue: T, context?: EvalContext, options?: GetFlagOptions): Flag<T>  getAllFlags(context?: EvalContext, options?: { fireExposures?: boolean }): Record<string, Flag>}const theme: Flag<string> = client.getStringFlag('homepage_theme', 'default')const forSomeoneElse: Flag<boolean> = client.getBoolFlag('new_checkout', false, {  kind: 'user',  key: 'u_999',  plan: 'pro',})
TypeScript19 lines

Every read returns a frozen Flag<T> carrying value, variationKey, source, ruleId, ruleType, reasons, evaluatedAt, durationMicros, isEnabled() and exists(). The SDK installation guide documents that shape field by field; it is identical in every A vs B SDK.

source says where the value came from:

ValueMeaning
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.
defaultNo rule matched, so the flag's default variation was served.
disabledThe flag is switched off, so its default variation was served.
not_foundThe datafile loaded and this key is not in it.
not_readyinit() had not finished, so your defaultValue was returned.
Warning

A read before init() finishes reports source: 'not_ready', not not_found, and warns once per flag key. The two mean different things: not_found is "the datafile does not contain this key", not_ready is "there is no datafile yet, ask again". Adapters await init() for you, so you only see not_ready when you build the client by hand.

A typed getter checks the value against the type the dashboard declares for that flag. On a mismatch it returns your default with source: 'not_found' and logs both types. It never coerces. getAllFlags() fires no exposures by default, because a bulk read is not a decision served to a visitor.

Tracking events

TypeScript
import type { AvsbEdgeClient } from '@avsbhq/edge'declare const edge: AvsbEdgeClientedge.track('signup_completed') // a countedge.track('purchase', { revenue: 49.99 }) // moneyedge.track('items_added', { value: 3 }) // a quantityedge.track('checkout_completed', { revenue: 99.5, value: 2 }) // both
TypeScript8 lines

revenue and value are two separate columns end to end, exactly as in every other A vs B SDK. revenue is money in decimal major units of the project currency; value is the numeric metric value an average-value metric averages (items, seats, seconds). One conversion can carry money, a quantity, or both.

Warning

Until 1.x this client sent payload.value into the revenue column and never wrote value at all, so a quantity was recorded as money and an edge-tracked average-value metric read empty. If your edge code tracks money, rename the field to revenue.

payload.properties is accepted for cross-runtime symmetry but is not stored on tracked events: the metric ingestion body has no properties field, so the SDK warns once rather than shipping something that looks delivered and is gone. Exposure events do carry properties.

Exposures are automatic: reading a flag whose decision came from an experiment queues one.

Flushing

flushEvents(): Promise<void> sends everything the request produced: queued exposures, queued tracked events, and any deferred cache write or heartbeat. Each adapter already calls it the way its runtime allows:

RuntimeHow the flush runs
Cloudflarectx.waitUntil, after the response
VercelwaitUntil when a FetchEvent is passed, otherwise awaited
Netlifycontext.waitUntil when present, otherwise awaited
Fastly, Deno, Lambda@EdgeAwaited before the response, because the instance ends with it
BunStarted without blocking, because Bun's loop outlives the response

An edge invocation ends with its request, so a batch that cannot be delivered is lost rather than retried later. The failure log says exactly that, naming the endpoint, the status, and how many events went with it. One retry inside the request is on by default, and every attempt sends byte-identical event ids so ingestion deduplicates instead of double-counting.

Lifecycle

TypeScript
import type { AvsbEdgeClient, FlagDatafile } from '@avsbhq/edge'declare const edgeClient: AvsbEdgeClientinterface EdgeLifecycle {  init(): Promise<InitResultShape>  getInitResult(): InitResultShape | null  isReady(): boolean  getDatafile(): FlagDatafile | null  flushEvents(): Promise<void>}interface InitResultShape {  success: boolean  source: 'network' | 'bootstrap' | 'timeout' | 'error'  error?: Error  degraded?: boolean}const result = await edgeClient.init()if (!result.success) {  // Nothing to serve. Every read returns the default you passed.}
TypeScript23 lines

init() never rejects and never runs twice: concurrent callers share one attempt.

SituationResult
A datafile was passed as a bootstrap{ success: true, source: 'bootstrap' }
A cached datafile was still fresh{ success: true, source: 'bootstrap' }
The datafile was fetched{ success: true, source: 'network' }
The fetch failed and a stale cached copy exists{ success: true, degraded: true, source: 'error' }
The fetch failed with nothing cached{ success: false, source: 'error' }

degraded: true means one thing only: a stale cached datafile is being served after a failed refresh. Flags still evaluate, and a change published since then is not visible yet.

There is nothing to close. An edge client has no timers, no sockets, and no global state.

Client options

TypeScript
import type { EdgeClientOptions } from '@avsbhq/edge'const options: EdgeClientOptions = {  sdkKey: 'sdk_production_ttqm0eaj4vth1krcb2xn',  cacheTtlMs: 300_000, // datafile freshness window, default 5 minutes  timeoutMs: 3000, // per datafile request  heartbeat: true, // report liveness, default true  maxQueueSize: 500,  eventRetryAttempts: 1,  logLevel: 'warn',}
TypeScript11 lines

Adapters own sdkKey, storage, waitUntil, context and pageUrl, because all five come from the request and the bindings you gave them. Everything else is passed through the adapter's clientOptions.

Caching is two separate ideas

  • Freshness is cacheTtlMs (default 5 minutes). Past it the client revalidates over the network, and if that fails it serves the stale copy with degraded: true.
  • Retention is how long an adapter's store keeps an entry at all. Cloudflare KV defaults to 24 hours (kvRetentionSeconds); the Vercel and Bun in-process maps default to 1 hour (memoryRetentionMs).

Keeping them apart is what makes the degraded path reachable: a store that hard-expired at the freshness window would leave nothing to fall back on when the CDN is unreachable.

Logging

The edge default is never silent. A browser console belongs to your end user; a Worker's log stream belongs to you, and there is no build step that flips a production define for a Worker. The default is console at warn, or debug when globalThis.__AVSB_DEV__ is true. Configuration-level messages are deduplicated for the life of the isolate, so a Worker serving ten thousand requests with a bad key logs it once. Set logLevel: 'silent' for no output at all.

The SDK never throws from a read, a track, or a flush.

Runtime adapters

ImportFactoryRuntime
@avsbhq/edge/cloudflarecreateCloudflareHandlerCloudflare Workers (KV datafile cache)
@avsbhq/edge/vercelcreateVercelHandlerVercel Edge Functions (Edge Config cache)
@avsbhq/edge/fastlycreateFastlyHandlerFastly Compute
@avsbhq/edge/netlifycreateNetlifyHandlerNetlify Edge Functions
@avsbhq/edge/denocreateDenoHandlerDeno and Deno Deploy
@avsbhq/edge/buncreateBunHandlerBun
@avsbhq/edge/lambdacreateLambdaEdgeHandlerLambda@Edge

Each adapter extracts an identity from the request in the way its runtime allows, and contextFrom overrides that when you know better. Adapters never invent a visitor id from the IP address.

Using the client directly

If you own the request lifecycle, skip the adapters:

TypeScript
import { AvsbEdgeClient, readVisitorId } from '@avsbhq/edge'export async function handle(req: Request): Promise<Response> {  const client = new AvsbEdgeClient({    sdkKey: process.env.AVSB_SDK_KEY ?? '',    context: { kind: 'user', key: readVisitorId(req.headers) ?? 'anonymous' },    pageUrl: req.url,  })  await client.init()  const flag = client.getBoolFlag('new_checkout', false)  const response = Response.json({ enabled: flag.isEnabled() })  await client.flushEvents()  return response}
TypeScript17 lines

Keeping the datafile fresh

Edge isolates are short-lived and cache the datafile per cold start, so a published change lands within cacheTtlMs at the latest. To pick it up sooner, register a flag.published webhook (Settings, then Webhooks) whose receiver fetches the new datafile and writes it into your runtime's store. New isolates then start from the fresh copy.

The webhook payload carries the project and environment that published, not the datafile itself, so the receiver fetches the datafile before writing it. The Cloudflare Workers and Deno Deploy guides show a receiver end to end.

What's next

Was this helpful?