OpenFeature (JavaScript)
OpenFeature is a vendor-neutral API for reading feature flags. Your application code calls client.getBooleanValue(...), and a provider underneath decides where the answer comes from.
This page is the honest state of that integration for JavaScript and TypeScript: what A vs B ships today, the small amount of glue the OpenFeature server SDK needs on top of it, and the parts of A vs B that the OpenFeature API cannot express.
If you are choosing between the two, the native @avsbhq/browser and @avsbhq/node SDKs give you the full surface (multi-context identity, bandits, sticky bucketing, decision logs, evaluation reasons). Use OpenFeature when your codebase is already written against it.
What ships today
A vs B publishes two adapter classes on the @avsbhq/utils/openfeature entry point:
// Both classes, and every type named below, come from// '@avsbhq/utils/openfeature'.import type { BrowserEvaluator, OpenFeatureEvaluationContext, ResolutionDetails, ServerEvaluator,} from '@avsbhq/utils/openfeature'import type { TrackPayload } from '@avsbhq/core'// The server one, wrapping AvsbServer from @avsbhq/nodeclass AvsbProvider { metadata: { name: 'avsb' } constructor(server: ServerEvaluator) resolveBooleanEvaluation(flagKey: string, defaultValue: boolean, ctx: OpenFeatureEvaluationContext): ResolutionDetails<boolean> resolveStringEvaluation(flagKey: string, defaultValue: string, ctx: OpenFeatureEvaluationContext): ResolutionDetails<string> resolveNumberEvaluation(flagKey: string, defaultValue: number, ctx: OpenFeatureEvaluationContext): ResolutionDetails<number> resolveObjectEvaluation<T>(flagKey: string, defaultValue: T, ctx: OpenFeatureEvaluationContext): ResolutionDetails<T> track(eventKey: string, ctx: OpenFeatureEvaluationContext, payload?: TrackPayload): void}// The browser one, wrapping AvsbClient from @avsbhq/browserclass AvsbWebProvider { metadata: { name: 'avsb-web' } constructor(client: BrowserEvaluator) resolveBooleanEvaluation(flagKey: string, defaultValue: boolean): ResolutionDetails<boolean> resolveStringEvaluation(flagKey: string, defaultValue: string): ResolutionDetails<string> resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails<number> resolveObjectEvaluation<T>(flagKey: string, defaultValue: T): ResolutionDetails<T>}Two things about them are worth knowing before you wire either one up:
- They do not import the OpenFeature packages. They are written against the shapes OpenFeature defines, so installing
@avsbhq/utilsdoes not pull an OpenFeature SDK into your build. In practice that means TypeScript is comparing two independent declarations of the same shape, and it can reject the handoff. The wrapper in the next section is the fix, and it is short. - Resolution is synchronous in both. That matches the OpenFeature web SDK, which resolves synchronously. The OpenFeature server SDK expects providers to resolve asynchronously, which is what the wrapper below is mostly doing.
There is no published @avsbhq/openfeature package, no hosted OFREP endpoint, and no OpenFeature conformance suite in our CI. What is on this page is what exists.
Server (@openfeature/server-sdk)
Wrap the shipped adapter in a provider typed against the OpenFeature package you installed. This is the whole thing, and it compiles under strict mode:
// avsb-provider.ts// docs-example: not typechecked here, because this repo does not install// @openfeature/server-sdk. Copy it into an app that does.import { type EvaluationContext, type JsonValue, type Provider, type ResolutionDetails,} from '@openfeature/server-sdk'import { AvsbProvider as AvsbResolver } from '@avsbhq/utils/openfeature'import type { AvsbServer } from '@avsbhq/node'export class AvsbOpenFeatureProvider implements Provider { readonly metadata = { name: 'avsb' } as const readonly runsOn = 'server' as const private readonly resolver: AvsbResolver constructor(private readonly server: AvsbServer) { this.resolver = new AvsbResolver(server) } async initialize(): Promise<void> { await this.server.onReady() } async onClose(): Promise<void> { await this.server.close() } async resolveBooleanEvaluation( flagKey: string, defaultValue: boolean, context: EvaluationContext, ): Promise<ResolutionDetails<boolean>> { const { value, variant, reason } = this.resolver.resolveBooleanEvaluation( flagKey, defaultValue, context, ) return { value, variant, reason } } async resolveStringEvaluation( flagKey: string, defaultValue: string, context: EvaluationContext, ): Promise<ResolutionDetails<string>> { const { value, variant, reason } = this.resolver.resolveStringEvaluation( flagKey, defaultValue, context, ) return { value, variant, reason } } async resolveNumberEvaluation( flagKey: string, defaultValue: number, context: EvaluationContext, ): Promise<ResolutionDetails<number>> { const { value, variant, reason } = this.resolver.resolveNumberEvaluation( flagKey, defaultValue, context, ) return { value, variant, reason } } async resolveObjectEvaluation<T extends JsonValue>( flagKey: string, defaultValue: T, context: EvaluationContext, ): Promise<ResolutionDetails<T>> { const { value, variant, reason } = this.resolver.resolveObjectEvaluation<T>( flagKey, defaultValue, context, ) return { value, variant, reason } }}Then register it once, at startup:
// docs-example: not typechecked here, because this repo does not install// @openfeature/server-sdk.import { OpenFeature } from '@openfeature/server-sdk'import { AvsbServer } from '@avsbhq/node'import { AvsbOpenFeatureProvider } from './avsb-provider'const sdkKey = process.env.AVSB_SDK_KEYif (!sdkKey) throw new Error('AVSB_SDK_KEY is not set')const server = new AvsbServer({ sdkKey })await OpenFeature.setProviderAndWait(new AvsbOpenFeatureProvider(server))const client = OpenFeature.getClient()const details = await client.getBooleanDetails('new_checkout_flow', false, { targetingKey: 'u_123', plan: 'pro',})// details.value, details.variant, details.reasonsetProviderAndWait matters: it awaits initialize(), which awaits the first datafile. Registering without waiting means the first few evaluations run before any flag configuration exists, and every one of them returns the default you passed.
Browser (@openfeature/web-sdk)
The web SDK resolves synchronously, which is what the shipped adapter already does:
// docs-example: not typechecked here, because this repo does not install// @openfeature/web-sdk.import { OpenFeature } from '@openfeature/web-sdk'import { AvsbWebProvider } from '@avsbhq/utils/openfeature'import { AvsbClient } from '@avsbhq/browser'const avsb = new AvsbClient({ sdkKey: import.meta.env.VITE_AVSB_SDK_KEY })await avsb.onReady()await OpenFeature.setProviderAndWait(new AvsbWebProvider(avsb))const client = OpenFeature.getClient()const showBanner = client.getBooleanValue('show-promo-banner', false)If TypeScript rejects new AvsbWebProvider(avsb) where a Provider is expected, wrap it the same way as the server example: a class that implements Provider, holding an AvsbWebProvider and returning { value, variant, reason } from each resolver.
Identity in the browser
Either path works, and they do the same thing.
OpenFeature.setContext(...) reaches the A vs B client: the web adapter's onContextChange calls identify on it, so every read after that evaluates, buckets and attributes against the new subject. The whole context travels, not just the targeting key.
await OpenFeature.setContext({ targetingKey: 'u_123', plan: 'pro' })// equivalent to:avsb.identify({ kind: 'user', key: 'u_123', plan: 'pro' })Use identify directly when you want a kind other than user, which OpenFeature's single-context model cannot express.
Worth knowing if you are upgrading: this used to be a silent no-op. The adapter forwarded context changes to a setContext method the browser SDK does not have, and because the call was optional it compiled and did nothing, so flags kept evaluating for the previous visitor. If you added an identify call as a workaround, it is still correct and still the same operation; you can keep it or drop it.
How the two models map
| OpenFeature | A vs B | Notes |
|---|---|---|
targetingKey | context.key | The bucketing identifier. |
| Any other context property | A targeting attribute | Attached to a user kind context. |
| Multi-kind context | Not expressible | The adapter always builds a single user context. Use the native SDK for { kind: 'multi', … }. |
ResolutionDetails.value | Flag.value | |
ResolutionDetails.variant | Flag.variationKey | undefined when the default was served. |
ResolutionDetails.reason | Flag.source, upper-cased | RULE, HOLDOUT, BANDIT, STICKY, DATAFILEOVERRIDE, RUNTIMEOVERRIDE, DEFAULT, DISABLED. These are the A vs B sources upper-cased whole (no separator added), not OpenFeature's standard reason strings. The two error sources are the exception, see the row below. |
flagMetadata | ruleId, ruleType, durationMicros | |
errorCode | Set for the two error sources | A flag that is missing, or whose declared type contradicts the getter you called, resolves as reason: 'ERROR' with errorCode: 'FLAG_NOT_FOUND'. Reading before the SDK is ready gives errorCode: 'PROVIDER_NOT_READY'. Your default value is still returned in both cases, and errorMessage says which of the two it was. |
| Provider hooks, events, status | Not implemented |
The typed getters are honoured
getBooleanValue, getStringValue, getNumberValue and getObjectValue each route through the matching A vs B typed getter, so the type the platform declares for a flag is checked at read time. Call getBooleanValue on a flag configured as JSON and you get your default back with reason: 'ERROR', not the object mislabelled as a boolean.
One rough edge to know about: a type mismatch and a genuinely missing flag both report errorCode: 'FLAG_NOT_FOUND'. The SDK returns the same "no usable value" result for both, and only errorMessage distinguishes them. Read that string when you need to tell the two apart.
Evaluations record exposures
Every resolution goes through the normal A vs B evaluation path, so it records an exposure for experiment rules exactly as a native read does. That is usually what you want on the server, where a read happens once per request for one visitor.
In the browser it deserves a second thought: OpenFeature reads are cheap and code tends to call them during rendering, and every one of those is an exposure. If you read a flag in a component that re-renders, prefer the native @avsbhq/browser client with { readOnly: true }, or one of the framework SDKs, whose render-path reads never fire exposures.
Tracking events
The server adapter forwards OpenFeature's tracking call:
// docs-example: not typechecked here, because this repo does not install// @openfeature/server-sdk.resolver.track('checkout_started', { targetingKey: 'u_123' }, { value: 99 })The web adapter has no tracking method. Call the A vs B client directly:
avsb.track('checkout_started', { value: 99 })When to use the native SDK instead
- Multi-context targeting: one evaluation against a user and an organization at once.
- Bandits and holdouts you want to reason about, using
flag.sourceandflag.reasons. - Sticky bucketing with your own storage.
- Decision logs for auditing what a request decided.
- Exposure control: render-safe reads, and
manualExposurewhere the visitor actually sees the variation.
Everything above is available through the OpenFeature adapters only as far as ResolutionDetails can carry it, which is the value, the variant, and a reason string.