npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@avsbhq/angular

v1.1.0

Published

Angular SDK for A vs B feature flags and experiments: signals, RxJS observables, a structural directive, and pipes.

Readme

@avsbhq/angular

Angular SDK for A vs B feature flags and experiments.

Signals and Observables for the same flags, a structural directive with an else branch, two pipes, and exposure tracking you control. Built with ng-packagr, so it compiles under AOT in a normal Angular CLI application.


1. Install

npm install @avsbhq/angular

@angular/core (20 or later) and rxjs (7 or later) are peer dependencies, so they come from your application. @avsbhq/core and @avsbhq/browser are regular dependencies: you do not install them yourself.


2. Quickstart

Standalone bootstrap

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAvsb } from '@avsbhq/angular';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [
    provideAvsb({
      sdkKey: 'sdk_production_ttqm0eaj4vth1krcb2xn',
      context: { kind: 'user', key: 'anon_42' },
    }),
  ],
});

NgModule bootstrap

// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AvsbModule } from '@avsbhq/angular';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, AvsbModule.forRoot({ sdkKey: 'sdk_production_ttqm0eaj4vth1krcb2xn' })],
  bootstrap: [AppComponent],
})
export class AppModule {}

Configure once

Call provideAvsb() (or AvsbModule.forRoot()) exactly once, at bootstrap. Calling it again in a lazy route's providers builds a SECOND client for that route: deliberate when you want a different context there, a duplicate datafile fetch and a duplicate event queue when you do not. Components in that route then resolve the route's client, and everything outside keeps the root one.

Feature modules import plain AvsbModule (no forRoot) to get the directives and pipes; that adds no providers, so they reuse the root client.

Read a flag

import { Component } from '@angular/core';
import { avsbBoolFlagSignal, AVSB_TEMPLATE_FEATURES } from '@avsbhq/angular';
import type { Flag } from '@avsbhq/angular';
import type { Signal } from '@angular/core';

@Component({
  selector: 'app-checkout',
  standalone: true,
  imports: [AVSB_TEMPLATE_FEATURES],
  template: `
    @if (checkout().isEnabled()) {
      <app-new-checkout avsbExposure="checkout-v2" />
    } @else {
      <app-legacy-checkout />
    }
  `,
})
export class CheckoutComponent {
  readonly checkout: Signal<Flag<boolean>> = avsbBoolFlagSignal('checkout-v2', false);
}

Three things are true of that component and of every read in this package:

  1. Reading a flag fires no exposure event. avsbExposure is what records that the visitor was shown the decision, so change detection can never inflate your results.
  2. Before the datafile arrives, checkout().value is the default you passed and checkout().source is 'not_ready'. Nothing is undefined.
  3. The signal updates by itself when the flag changes in the dashboard.

3. SDK keys

There is one SDK key per environment, and it is the only credential this package takes. There is no separate client key or server key.

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.

Find yours on app.avsb.cloud under Settings > Environments. Keys are shaped sdk_<environment>_<id>, so a production key reads sdk_production_....

// environments/environment.ts
export const environment = {
  avsbSdkKey: 'sdk_production_ttqm0eaj4vth1krcb2xn',
};

4. The whole type surface

Every export, with its real signature. The names in the shapes below come from this package, from Angular, and from rxjs:

import type {
  EnvironmentProviders,
  InjectionToken,
  Injector,
  ModuleWithProviders,
  Signal,
  TemplateRef,
} from '@angular/core';
import { Directive, Input, Pipe } from '@angular/core';
import type { Observable } from 'rxjs';
import {
  AvsbExposureDirective,
  AvsbFlagDirective,
  AvsbFlagPipe,
  AvsbFlagStatePipe,
} from '@avsbhq/angular';
import type {
  AngularAvsbClient,
  AvsbClientOptions,
  AvsbConfig,
  AvsbInjectOptions,
  AvsbStatus,
  EvalContext,
  EvaluationSource,
  Flag,
  FlagDatafile,
  InitResult,
  TrackPayload,
} from '@avsbhq/angular';

Configuration

function provideAvsb(config: AvsbConfig): EnvironmentProviders;

class AvsbModule {
  static forRoot(config: AvsbConfig): ModuleWithProviders<AvsbModule>;
}

const AVSB_TEMPLATE_FEATURES: readonly [
  typeof AvsbFlagDirective,
  typeof AvsbExposureDirective,
  typeof AvsbFlagPipe,
  typeof AvsbFlagStatePipe,
];

const AVSB_CONFIG: InjectionToken<AvsbConfig>;
const AVSB_CLIENT: InjectionToken<AngularAvsbClient>;
type AvsbConfig = (AvsbClientOptions & { client?: never }) | { client: AngularAvsbClient };

Mode A passes sdkKey and any other AvsbClientOptions; this package builds the client and closes it when the injector is destroyed. Mode B passes a client you built yourself, which this package never closes.

AvsbClientOptions is re-exported unchanged from @avsbhq/browser; see that package's README for every option (pollingInterval, bootstrap, logLevel, streaming, and the rest).

AvsbService

Declared @Injectable({ providedIn: 'root' }) and implementing OnDestroy, so it is injectable anywhere and torn down with the injector:

class AvsbService {
  readonly client: AngularAvsbClient;

  // Status
  readonly status: Signal<AvsbStatus>;
  readonly status$: Observable<AvsbStatus>;
  readonly ready: Signal<boolean>;
  readonly ready$: Observable<boolean>;
  readonly degraded: Signal<boolean>;
  readonly degraded$: Observable<boolean>;
  readonly error: Signal<Error | null>;
  readonly error$: Observable<Error | null>;
  isReady(): boolean;
  onReady(options?: { timeout?: number }): Promise<InitResult>;

  // Reads, as Observables
  getFlag<T>(flagKey: string, defaultValue: T): Observable<Flag<T>>;
  getFlagValue<T>(flagKey: string, defaultValue: T): Observable<T>;
  getBoolFlag(flagKey: string, defaultValue: boolean): Observable<Flag<boolean>>;
  getStringFlag(flagKey: string, defaultValue: string): Observable<Flag<string>>;
  getNumberFlag(flagKey: string, defaultValue: number): Observable<Flag<number>>;
  getJsonFlag<T>(flagKey: string, defaultValue: T): Observable<Flag<T>>;
  getAllFlags(): Observable<Record<string, Flag>>;
  snapshot<T>(flagKey: string, defaultValue: T): Flag<T>;

  // Reads, as Signals (injection context required, see section 5)
  flagSignal<T>(flagKey: string, defaultValue: T, options?: AvsbInjectOptions): Signal<Flag<T>>;
  flagValueSignal<T>(flagKey: string, defaultValue: T, options?: AvsbInjectOptions): Signal<T>;
  boolFlagSignal(
    flagKey: string,
    defaultValue: boolean,
    options?: AvsbInjectOptions,
  ): Signal<Flag<boolean>>;
  stringFlagSignal(
    flagKey: string,
    defaultValue: string,
    options?: AvsbInjectOptions,
  ): Signal<Flag<string>>;
  numberFlagSignal(
    flagKey: string,
    defaultValue: number,
    options?: AvsbInjectOptions,
  ): Signal<Flag<number>>;
  jsonFlagSignal<T>(flagKey: string, defaultValue: T, options?: AvsbInjectOptions): Signal<Flag<T>>;
  allFlagsSignal(options?: AvsbInjectOptions): Signal<Record<string, Flag>>;

  // Exposure
  recordExposure(flagKey: string, defaultValue?: unknown): () => void;

  // Identity and events
  identify(context: EvalContext): void;
  alias(previousContext: EvalContext, newContext: EvalContext): void;
  reset(): void;
  track(eventKey: string, payload?: TrackPayload): void;
  flush(): Promise<void>;

  // Teardown
  destroy(): void;
  ngOnDestroy(): void;
}

Injecting AvsbService in an application that called neither provideAvsb() nor AvsbModule.forRoot() throws, with a message naming the call to add. The alternative was an application that silently served default values forever.

Standalone helpers

Each helper is the terse form of the matching service method. Every name carries the avsb prefix, so nothing here can collide with a track or identify of your own.

// Observables
function avsbFlag$<T>(
  flagKey: string,
  defaultValue: T,
  options?: AvsbInjectOptions,
): Observable<Flag<T>>;
function avsbFlagValue$<T>(
  flagKey: string,
  defaultValue: T,
  options?: AvsbInjectOptions,
): Observable<T>;
function avsbBoolFlag$(
  flagKey: string,
  defaultValue: boolean,
  options?: AvsbInjectOptions,
): Observable<Flag<boolean>>;
function avsbStringFlag$(
  flagKey: string,
  defaultValue: string,
  options?: AvsbInjectOptions,
): Observable<Flag<string>>;
function avsbNumberFlag$(
  flagKey: string,
  defaultValue: number,
  options?: AvsbInjectOptions,
): Observable<Flag<number>>;
function avsbJsonFlag$<T>(
  flagKey: string,
  defaultValue: T,
  options?: AvsbInjectOptions,
): Observable<Flag<T>>;
function avsbAllFlags$(options?: AvsbInjectOptions): Observable<Record<string, Flag>>;
function avsbStatus$(options?: AvsbInjectOptions): Observable<AvsbStatus>;
function avsbReady$(options?: AvsbInjectOptions): Observable<boolean>;
function avsbError$(options?: AvsbInjectOptions): Observable<Error | null>;
function avsbDegraded$(options?: AvsbInjectOptions): Observable<boolean>;

// Signals
function avsbFlagSignal<T>(
  flagKey: string,
  defaultValue: T,
  options?: AvsbInjectOptions,
): Signal<Flag<T>>;
function avsbFlagValueSignal<T>(
  flagKey: string,
  defaultValue: T,
  options?: AvsbInjectOptions,
): Signal<T>;
function avsbBoolFlagSignal(
  flagKey: string,
  defaultValue: boolean,
  options?: AvsbInjectOptions,
): Signal<Flag<boolean>>;
function avsbStringFlagSignal(
  flagKey: string,
  defaultValue: string,
  options?: AvsbInjectOptions,
): Signal<Flag<string>>;
function avsbNumberFlagSignal(
  flagKey: string,
  defaultValue: number,
  options?: AvsbInjectOptions,
): Signal<Flag<number>>;
function avsbJsonFlagSignal<T>(
  flagKey: string,
  defaultValue: T,
  options?: AvsbInjectOptions,
): Signal<Flag<T>>;
function avsbAllFlagsSignal(options?: AvsbInjectOptions): Signal<Record<string, Flag>>;
function avsbStatusSignal(options?: AvsbInjectOptions): Signal<AvsbStatus>;
function avsbReadySignal(options?: AvsbInjectOptions): Signal<boolean>;
function avsbErrorSignal(options?: AvsbInjectOptions): Signal<Error | null>;
function avsbDegradedSignal(options?: AvsbInjectOptions): Signal<boolean>;

// Actions
function avsbTrack(eventKey: string, payload?: TrackPayload, options?: AvsbInjectOptions): void;
function avsbIdentify(context: EvalContext, options?: AvsbInjectOptions): void;
function avsbAlias(
  previousContext: EvalContext,
  newContext: EvalContext,
  options?: AvsbInjectOptions,
): void;
function avsbReset(options?: AvsbInjectOptions): void;
function avsbRecordExposure(
  flagKey: string,
  defaultValue?: unknown,
  options?: AvsbInjectOptions,
): () => void;
interface AvsbInjectOptions {
  injector?: Injector;
}

type AvsbStatus = 'loading' | 'ready' | 'error';

Template features

@Directive({ selector: '[avsbFlag]', standalone: true })
class AvsbFlagDirective {
  @Input({ required: true }) avsbFlag!: string;
  @Input() avsbFlagDefault: unknown = false;
  @Input() avsbFlagWhenVariation?: string;
  @Input() avsbFlagWhenValue?: unknown;
  @Input() avsbFlagElse: TemplateRef<unknown> | null = null;
  @Input() avsbFlagExposure = false;
}

@Directive({ selector: '[avsbExposure]', standalone: true })
class AvsbExposureDirective {
  @Input({ required: true }) avsbExposure!: string;
  @Input() avsbExposureDefault: unknown = null;
}

The two pipes are declared @Pipe({ name: 'avsbFlag', standalone: true, pure: false }) and @Pipe({ name: 'avsbFlagState', standalone: true, pure: false }). Impure on purpose: a pure pipe would cache the first value and never see the flag change.

class AvsbFlagPipe {
  transform<T>(flagKey: string, defaultValue: T): T;
}

class AvsbFlagStatePipe {
  transform<T>(flagKey: string, defaultValue: T): Flag<T>;
}

Types re-exported for convenience

Flag, EvalContext, SingleContext, MultiContext, TrackPayload, InitResult, EvaluationSource, RuleType, ClientEventMap, ClientEventName, FlagDatafile and its sub-types, Logger, AvsbClientOptions, GetFlagOptions, GetAllFlagsOptions, plus the DecideOption const object and the createFlag / notFoundFlag / notReadyFlag factories.

Typed flag keys

Every flagKey above is string until you generate your keys. Generate them and it becomes the union of this project's real flag keys, so a typo is a compile error and your editor completes the list:

npx avsb codegen --output src/generated/flags.ts

The generated file declares your flags twice on purpose: an importable FlagValues interface for payload types, and a declare global block that teaches the service, the helpers, the pipes and the directives which keys exist. Its important parts:

// AUTO-GENERATED by @avsbhq/cli codegen. Do not edit by hand.

export interface FlagValues {
  'checkout-v2': boolean
  'hero-copy': 'control' | 'variant-a'
  'theme': { primary: string }
}

declare global {
  interface AvsbFlags {
    'checkout-v2': boolean
    'hero-copy': 'control' | 'variant-a'
    'theme': { primary: string }
  }
}
import { avsbBoolFlagSignal, avsbJsonFlagSignal } from '@avsbhq/angular';
import type { FlagValues } from './generated/flags';

class TypedCheckoutComponent {
  readonly checkout = avsbBoolFlagSignal('checkout-v2', false);

  // A JSON flag types its payload from the generated table:
  readonly theme = avsbJsonFlagSignal<FlagValues['theme']>('theme', { primary: '#111' });

  // Once the generated file exists, this line stops compiling:
  // 'chekcout-v2' is not a flag key.
  readonly typo = avsbBoolFlagSignal('chekcout-v2', false);
}

One real consequence in templates: a literal binding such as *avsbFlag="'checkout-v2'" or {{ 'hero-copy' | avsbFlag: 'control' }} is checked against your keys and otherwise unaffected. A binding that passes a computed string, [avsbFlag]="someKey", needs the same cast on someKey that TypeScript code does.

Nothing changes for a project that never runs codegen: with no generated file the table is empty, every flagKey is exactly string, and every call and binding you have already written compiles unchanged. For a key computed at runtime, widen deliberately with key as AvsbFlagKey (that type is exported from @avsbhq/core).


5. Injection context: what needs one

Some helpers ask Angular for the current injector. Angular only offers one while it is constructing something.

Safe anywhere:

  • every method on an injected AvsbService that returns an Observable (getFlag, getFlagValue, the typed getters, getAllFlags);
  • snapshot(), which answers synchronously with a Flag<T>;
  • every action on it (track, identify, alias, reset, recordExposure);
  • the directives and pipes, which Angular constructs for you.

Needs an injection context, or an explicit injector:

  • every standalone helper, because it has to find AvsbService;
  • every *Signal method and helper, because the injector also decides when the subscription is released.

An injection context is: a field initializer, a constructor, a factory function, and anything inside runInInjectionContext(). It is not ngOnInit, an event handler, a setTimeout callback, or a promise continuation.

import { Component, inject, Injector, OnInit, Signal } from '@angular/core';
import { avsbFlagSignal } from '@avsbhq/angular';
import type { Flag } from '@avsbhq/angular';

@Component({ selector: 'app-hero', standalone: true, template: '' })
export class HeroComponent implements OnInit {
  // Field initializer: an injection context, so no options are needed.
  readonly hero: Signal<Flag<string>> = avsbFlagSignal('homepage-hero', 'control');

  private readonly injector = inject(Injector);
  late: Signal<Flag<string>> | null = null;

  ngOnInit(): void {
    // Lifecycle hook: not an injection context, so pass the injector.
    this.late = avsbFlagSignal('late-flag', 'control', { injector: this.injector });
  }
}

Called outside an injection context with no injector, a helper throws Angular's NG0203 naming the helper you called.


6. Reading flags

Signals

readonly hero: Signal<Flag<string>> = avsbStringFlagSignal('homepage-hero', 'control')
readonly heroValue: Signal<string> = avsbFlagValueSignal('homepage-hero', 'control')

The signal holds a Flag from the moment it is created: the first value is read synchronously, so the type is Signal<Flag<string>> and never Signal<Flag<string> | undefined>.

Observables

private readonly avsb = inject(AvsbService)
readonly hero$: Observable<Flag<string>> = this.avsb.getStringFlag('homepage-hero', 'control')
@if (hero$ | async; as hero) {
<app-hero [variant]="hero.value" />
}

async is Angular's own AsyncPipe, from @angular/common. This package does not import @angular/common, so it is not a peer dependency here.

Each Observable emits the current value on subscribe, then once per change to that flag. An unrelated flag changing does not emit. It never errors and never completes, so catchError is unnecessary.

In a template, with the structural directive

<!-- the flag is on for this visitor -->
<section *avsbFlag="'new-dashboard'">...</section>

<!-- with a fallback branch -->
<section *avsbFlag="'new-dashboard'; else legacyDashboard">...</section>
<ng-template #legacyDashboard>...</ng-template>

<!-- one variation of a multivariate flag, by variation key -->
<section *avsbFlag="'hero'; default: 'control'; whenVariation: 'variant-a'">...</section>

<!-- or by the value that variation carries -->
<section *avsbFlag="'hero'; default: 'control'; whenValue: 'blue'">...</section>

<!-- record the exposure when this branch is shown -->
<section *avsbFlag="'new-dashboard'; exposure: true">...</section>

With no matcher, the directive renders when flag.isEnabled() is true.

whenVariation compares Flag.variationKey, the name shown in the dashboard. whenValue compares Flag.value with Object.is, so a falsy value such as 0 or '' matches correctly. Setting both means both must match, and the package logs a warning saying so.

In a template, with the pipes

<h1>{{ 'homepage-hero' | avsbFlag: 'Welcome' }}</h1>

@let state = 'checkout-v2' | avsbFlagState: false; @if (state.source === 'not_ready') {
<app-skeleton />
} @else if (state.isEnabled()) {
<app-new-checkout />
}

avsbFlag gives the value, typed from the default you pass. avsbFlagState gives the whole Flag, which is how a template tells "no datafile yet" apart from a real false.

Both pipes are impure: flag values change over time rather than when the template's inputs change. Each subscribes once per key and marks the view for check on updates, so OnPush components re-render.

Keep the default value stable. A pipe re-subscribes when the key OR the default changes, and an object or array literal written inline in the template (| avsbFlagState: { retries: 3 }) is a new object on every change detection pass, so it would re-subscribe every pass. Hoist it to a field and pass that:

readonly apiConfigDefault = { timeout: 5000, retries: 3 }
{{ 'api-config' | avsbFlag: apiConfigDefault }}

Primitive defaults (false, 0, 'control') need no such care.


7. The Flag<T> object

Every read answers with this shape, from @avsbhq/core:

interface Flag<T = unknown> {
  /** The variation value typed against the default. */
  readonly value: T;
  /** Variation key (null if served the default or not found). */
  readonly variationKey: string | null;
  /** Why this value was produced. */
  readonly source: EvaluationSource;
  /** Rule that matched (null when no rule applied). */
  readonly ruleId: string | null;
  /** Rule type that matched (null when no rule applied). */
  readonly ruleType: RuleType | null;
  /** Structured reasons for this decision. */
  readonly reasons: string[];
  /** ms-epoch when evaluated. */
  readonly evaluatedAt: number;
  /** Microseconds elapsed in the evaluator. */
  readonly durationMicros: number;
  /** Convenience: a real decision produced a truthy value. */
  isEnabled(): boolean;
  /** Convenience: false for 'not_found' and for 'not_ready'. */
  exists(): boolean;
}

type RuleType = 'targeted_delivery' | 'ab_test' | 'holdout' | 'bandit';

The object is frozen, and the same object comes back on every read until that flag's value changes.

EvaluationSource, member by member

| Member | Meaning | isEnabled() | exists() | | ------------------ | ---------------------------------------------------------------------- | --------------- | ---------- | | datafileOverride | A per-user override configured in the dashboard matched. | value-dependent | true | | runtimeOverride | A runtime override set on the client matched. | value-dependent | true | | sticky | A previously stored assignment was reused. | value-dependent | true | | rule | A targeting rule or A/B rule matched. | value-dependent | true | | holdout | The visitor is in a holdout. | value-dependent | true | | bandit | A bandit rule picked the variation. | value-dependent | true | | default | The flag exists, nothing matched, its default variation was served. | false | true | | disabled | The flag exists but is switched off in this environment. | false | true | | not_found | The datafile loaded and does not contain this key. Check the key name. | false | false | | not_ready | The SDK has no datafile yet. Wait for ready, or pass bootstrap. | false | false |

"value-dependent" means isEnabled() is Boolean(flag.value).

Typed reads and mismatches

getBoolFlag, getStringFlag, getNumberFlag and their signal and helper forms check the value against the type the platform declared for that flag. A mismatch never throws. The SDK logs one warning naming the flag and the getter, then answers with your defaultValue and source: 'not_found'.

getJsonFlag<T> checks only that the flag is declared as JSON. T is your assertion about the payload shape, not a runtime check: validate it yourself if it crosses a trust boundary.

getFlag<T> runs no type check at all. T comes from your type argument when you give one, and from the default value when you do not.


8. Status, readiness and errors

readonly status: Signal<AvsbStatus>       // 'loading' | 'ready' | 'error'
readonly ready: Signal<boolean>
readonly degraded: Signal<boolean>
readonly error: Signal<Error | null>

| Situation | status | degraded | error | | -------------------------------------------------------------- | ----------- | ---------- | ----------------- | | The first datafile load is in flight. | 'loading' | false | null | | The datafile loaded. | 'ready' | false | null | | A cached datafile is being served after a failed refresh. | 'ready' | true | the refresh error | | Nothing could be loaded. Every flag answers with your default. | 'error' | false | the init error | | A later refresh rescued a failed load. | 'ready' | false | null |

Degraded is a warning, never a failure: the values are real, they may be out of date, and polling continues behind them. The SDK emits an error event on its way to a degraded state, and this package deliberately does not treat that event as fatal.

@Component({
  /* ... */
})
export class AppComponent {
  private readonly avsb = inject(AvsbService);
  readonly status = this.avsb.status;
  readonly error = this.avsb.error;
}
@if (status() === 'error') {
<app-banner [text]="error()?.message ?? 'Flags unavailable'" />
}

error()?.message is written for a person: it names what failed, the value involved, and where to fix it.

For a one-shot check, onReady() resolves when the init attempt settles and never rejects:

export class ReadyGate {
  private readonly avsb = inject(AvsbService);

  async load(): Promise<void> {
    const result: InitResult = await this.avsb.onReady({ timeout: 2000 });
    if (!result.success) {
      // result.error.message names the URL tried and the fix.
    }
  }
}

The timeout bounds how long you wait, not what the SDK does: loading continues behind it.


9. Exposure: keeping experiment results honest

Reads in this package fire no exposure event. That is what makes them safe to call during change detection, which Angular may do many times per second.

Record the exposure where the decision is actually shown:

<section *avsbFlag="'checkout-v2'">
  <app-new-checkout avsbExposure="checkout-v2" />
</section>

or let the structural directive do it when the branch appears:

<section *avsbFlag="'checkout-v2'; exposure: true">
  <app-new-checkout />
</section>

or from TypeScript, when the variation arrives as an input from elsewhere:

private readonly avsb = inject(AvsbService)

ngOnInit(): void {
  this.avsb.recordExposure('checkout-v2')
}

Called before the SDK is ready, recordExposure waits for the first datafile and records then. It returns a cancel function for that wait, which the avsbExposure directive calls if the element is destroyed first: a visitor who never saw the variation should not appear in the results.


10. Identity

const avsb = inject(AvsbService);

// After sign-in
avsb.identify({ kind: 'user', key: 'user_123', plan: 'pro', country: 'GB' });

// Link the anonymous visitor to the account, once, at sign-in, and after
// identify(): the event is attributed to the identity the client is bound to
avsb.alias({ kind: 'user', key: 'anon_42' }, { kind: 'user', key: 'user_123' });

// On sign-out
avsb.reset();

identify replaces the whole context and re-evaluates every flag immediately, so subscribed components and signals update.

Target on several dimensions at once with a multi-context:

avsb.identify({
  kind: 'multi',
  user: { kind: 'user', key: 'user_123', plan: 'pro' },
  organization: { kind: 'organization', key: 'org_456', tier: 'enterprise' },
});

11. Tracking events

avsb.track('checkout_clicked');

avsb.track('purchase_completed', {
  value: 149.99,
  properties: { currency: 'GBP', productId: 'prod_123' },
});
interface TrackPayload {
  /** Numeric metric value. Generalises 'revenue': any quantity. */
  value?: number;
  /** Free-form properties forwarded to analytics. */
  properties?: Record<string, unknown>;
  /** Ignored by browser SDKs; the bound context is always used. */
  context?: EvalContext;
}

Events are batched. avsb.flush() sends what is queued now, which is worth doing before a full page navigation.


12. Testing

Mode B is the whole testing story: provide a client, and every read in your components answers from it.

import { TestBed } from '@angular/core/testing';
import { provideAvsb, createFlag } from '@avsbhq/angular';
import type { AngularAvsbClient, Flag } from '@avsbhq/angular';
import { CheckoutComponent } from './checkout.component';

const enabled: Flag<boolean> = createFlag<boolean>({
  value: true,
  variationKey: 'on',
  source: 'rule',
  ruleId: 'rule_1',
  ruleType: 'ab_test',
  reasons: ['matched rule rule_1'],
});

const client: AngularAvsbClient = {
  // ... the AngularAvsbClient members your component reaches
} as AngularAvsbClient;

TestBed.configureTestingModule({
  imports: [CheckoutComponent],
  providers: [provideAvsb({ client })],
});

AngularAvsbClient is the exact surface a stand-in has to satisfy, and it is exported for that reason. The service calls four of its methods while it is being constructed, so those are the ones a stand-in always needs: getInitResult(), isReady(), onReady(), and on() (twice: once for 'ready', once for 'error'). Reads then use subscribe(flagKey, listener) and getSnapshot(flagKey, defaultValue), or the typed getters with { readOnly: true }.

AvsbService takes no constructor arguments: it reads AVSB_CLIENT and AVSB_CONFIG with inject(). To build one without TestBed, give it an injector carrying those two tokens:

import { Injector, runInInjectionContext } from '@angular/core';
import { AVSB_CLIENT, AVSB_CONFIG, AvsbService } from '@avsbhq/angular';

const injector = Injector.create({
  providers: [
    { provide: AVSB_CLIENT, useValue: client },
    { provide: AVSB_CONFIG, useValue: { client } },
  ],
});
const service = runInInjectionContext(injector, () => new AvsbService());

13. Server rendering

There is no Angular Universal integration yet. Under SSR the SDK renders default values, then the browser evaluates for real once the datafile arrives.

If you already fetch the datafile on the server, pass it as bootstrap so the browser starts ready instead of fetching again:

declare const datafileFromTheServer: FlagDatafile;

provideAvsb({
  sdkKey: 'sdk_production_ttqm0eaj4vth1krcb2xn',
  bootstrap: datafileFromTheServer,
});

14. Shutdown

In Mode A the client is closed when the injector that provided it is destroyed, which flushes queued events. In Mode B nothing is closed: the client is yours. avsb.destroy() does the same work by hand.


15. Breaking changes in this release

Every public name is final. There are no deprecated aliases.

| Before | Now | | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | flag$, flagValue$, boolFlag$, stringFlag$, numberFlag$, jsonFlag$, allFlags$, flagReady$ | avsbFlag$, avsbFlagValue$, avsbBoolFlag$, avsbStringFlag$, avsbNumberFlag$, avsbJsonFlag$, avsbAllFlags$, avsbReady$ | | track, identify, alias | avsbTrack, avsbIdentify, avsbAlias | | boolFlag$ and friends emitted a bare value | the typed helpers emit Flag<T>; use avsbFlagValue$ for a bare value | | AvsbModuleConfig | AvsbConfig | | service.client$ (a mutable BehaviorSubject) | service.client (the client itself) | | *avsbFlag="'k'; when: 'v'" compared the value | whenVariation compares the variation key, whenValue compares the value | | a missing client left the SDK silently serving defaults | injecting AvsbService without provideAvsb() throws | | the pipe could return the default in place of a real value | the pipe returns the served value; avsbFlagState exposes not_ready and not_found | | built with tsup (no Ivy metadata, unusable under AOT) | built with ng-packagr (Angular Package Format, partial Ivy) | | peer @angular/core >= 17, @angular/common | peer @angular/core >= 20; @angular/common is not imported and is no longer a peer |

New in this release: the whole signal API, avsbFlagState, avsbExposure and recordExposure, else templates on *avsbFlag, degraded and error surfaces, avsbReset, and AVSB_TEMPLATE_FEATURES.


16. Migration from other tools

From LaunchDarkly

| LaunchDarkly | @avsbhq/angular | | -------------------------------------- | -------------------------------------------------------------- | | LDClient.variation(key, default) | avsbFlagSignal(key, default) or avsb.getFlag(key, default) | | ldClient.boolVariation(key, default) | avsbBoolFlagSignal(key, default) | | ldClient.allFlags() | avsbAllFlagsSignal() | | ldClient.on('change', cb) | subscribe to avsb.getFlag(...), or read the signal | | ldClient.identify(context) | avsb.identify(context) | | ldClient.track(key, data, metric) | avsb.track(key, { properties, value }) | | ldClient.waitForInitialization() | avsb.onReady() |

LaunchDarkly returns a bare value; this SDK returns a Flag, so read flag.value for the value and flag.isEnabled() for the on/off question.

From Statsig

| Statsig | @avsbhq/angular | | ------------------------------------------- | ------------------------------------- | | *stgCheckGate="'gate'" | *avsbFlag="'flag'" | | useGateValue (logs an exposure) | read plus avsbExposure | | checkGate (no exposure) | any read in this package | | getExperiment(name).get('param', default) | avsbJsonFlagSignal<T>(key, default) |


17. Set this up with your AI assistant

Paste this into Claude Code, Cursor, or any coding assistant:

Set up A vs B feature flags in this Angular project.

1. Run: npx @avsbhq/cli init
   Use my saved CLI login: do not ask me for a token and do not put one in any file.
   It detects Angular and writes an example component. Angular has no environment
   file convention, so the SDK key goes in the code: an A vs B SDK key is a public
   identifier and is expected to ship in the bundle.
2. Install @avsbhq/angular with this project's package manager.
3. Call provideAvsb({ sdkKey, context: { kind: 'user', key: <your user id> } })
   exactly once at bootstrap, or AvsbModule.forRoot(...) in an NgModule app. Keep
   the key in src/environments/environment.ts if you keep one per build target.
4. Read flags with avsbBoolFlagSignal(key, false). The signal holds a Flag object,
   so call flag().value or flag().isEnabled(), and always pass a fallback. Reading
   a flag records nothing: put avsbExposure="key" on the element that shows the
   variation.
5. Then run: npx @avsbhq/cli codegen

Done looks like: the app starts, the flag reads without throwing, and avsb init
prints the line confirming it saw the first check-in.

avsb init ends by waiting for your app's first check-in and printing what it saw, so the terminal tells you it works rather than the dashboard.