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.
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
npm install @avsbhq/edgeQuick start
Use the adapter for your runtime. It builds the client, binds a context from the request, awaits init(), and flushes events for you:
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 }) }, }),}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
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',})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:
| Value | Meaning |
|---|---|
rule | A targeting rule or experiment matched. |
holdout | This visitor is held out of the flag. |
bandit | A bandit rule chose the action. |
datafileOverride | An operator pinned this visitor in the dashboard. |
runtimeOverride | Your code pinned it at runtime. |
sticky | A stored assignment was reused. |
default | No rule matched, so the flag's default variation was served. |
disabled | The flag is switched off, so its default variation was served. |
not_found | The datafile loaded and this key is not in it. |
not_ready | init() had not finished, so your defaultValue was returned. |
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
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 }) // bothrevenue 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.
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:
| Runtime | How the flush runs |
|---|---|
| Cloudflare | ctx.waitUntil, after the response |
| Vercel | waitUntil when a FetchEvent is passed, otherwise awaited |
| Netlify | context.waitUntil when present, otherwise awaited |
| Fastly, Deno, Lambda@Edge | Awaited before the response, because the instance ends with it |
| Bun | Started 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
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.}init() never rejects and never runs twice: concurrent callers share one attempt.
| Situation | Result |
|---|---|
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
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',}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 withdegraded: 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
| Import | Factory | Runtime |
|---|---|---|
@avsbhq/edge/cloudflare | createCloudflareHandler | Cloudflare Workers (KV datafile cache) |
@avsbhq/edge/vercel | createVercelHandler | Vercel Edge Functions (Edge Config cache) |
@avsbhq/edge/fastly | createFastlyHandler | Fastly Compute |
@avsbhq/edge/netlify | createNetlifyHandler | Netlify Edge Functions |
@avsbhq/edge/deno | createDenoHandler | Deno and Deno Deploy |
@avsbhq/edge/bun | createBunHandler | Bun |
@avsbhq/edge/lambda | createLambdaEdgeHandler | Lambda@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:
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}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.