@avsbhq/vue 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Vue 3 SDK for the [A vs B](https://app.avsb.cloud) platform.
4
4
 
5
- Drop an `<AvsbProvider>` at the top of your component tree (or call `app.use(AvsbPlugin)`) and read feature flags from anywhere using composables. Built on `@avsbhq/browser`; components re-render automatically when flag values change.
5
+ Mount one `<AvsbProvider>` (or install `AvsbPlugin`), then read feature flags anywhere with composables. Built on [`@avsbhq/browser`](https://www.npmjs.com/package/@avsbhq/browser): components update when a flag's value changes, and only the components reading that flag.
6
6
 
7
7
  ---
8
8
 
@@ -12,7 +12,7 @@ Drop an `<AvsbProvider>` at the top of your component tree (or call `app.use(Avs
12
12
  npm install @avsbhq/vue
13
13
  ```
14
14
 
15
- Vue 3.4 or later is required as a peer dependency. Pinia integration is optional: install `pinia` only if you use `useAvsbStore`.
15
+ Vue 3.4 or later is required as a peer dependency. `pinia` is an optional peer: install it only if you import `@avsbhq/vue/pinia`.
16
16
 
17
17
  ---
18
18
 
@@ -21,11 +21,14 @@ Vue 3.4 or later is required as a peer dependency. Pinia integration is optional
21
21
  ```vue
22
22
  <!-- App.vue -->
23
23
  <script setup lang="ts">
24
- import { AvsbProvider } from '@avsbhq/vue'
24
+ import { AvsbProvider } from '@avsbhq/vue';
25
+
26
+ const sdkKey: string = import.meta.env['VITE_AVSB_SDK_KEY'] as string;
27
+ const userId = 'u_123';
25
28
  </script>
26
29
 
27
30
  <template>
28
- <AvsbProvider :sdk-key="sdkKey" :context="{ kind: 'user', key: userId }">
31
+ <AvsbProvider :sdk-key="sdkKey" :context="{ kind: 'user', key: userId, plan: 'pro' }">
29
32
  <RouterView />
30
33
  </AvsbProvider>
31
34
  </template>
@@ -34,330 +37,636 @@ import { AvsbProvider } from '@avsbhq/vue'
34
37
  ```vue
35
38
  <!-- CheckoutButton.vue -->
36
39
  <script setup lang="ts">
37
- import { useFlag, useTrack } from '@avsbhq/vue'
40
+ import { useBoolFlag, useExposure, useTrack } from '@avsbhq/vue';
41
+ import type { ComputedRef } from 'vue';
42
+ import type { Flag } from '@avsbhq/vue';
43
+
44
+ // Keep the ref: reading `.value` here instead would freeze the first value.
45
+ const newCheckout: ComputedRef<Flag<boolean>> = useBoolFlag('new-checkout-flow', false);
46
+ const track = useTrack();
38
47
 
39
- const newCheckout = useFlag('new-checkout-flow', false)
40
- const track = useTrack()
48
+ // The visitor is about to see this decision, so record it once.
49
+ useExposure('new-checkout-flow');
41
50
  </script>
42
51
 
43
52
  <template>
44
- <button @click="track('checkout_clicked')">
53
+ <button @click="track('checkout_started', { value: 99 })">
45
54
  {{ newCheckout.value ? 'Start checkout (new)' : 'Buy now' }}
46
55
  </button>
47
56
  </template>
48
57
  ```
49
58
 
59
+ Both props matter: without `context` the visitor gets a persisted anonymous id, which is fine for anonymous experiments but cannot target logged-in attributes.
60
+
50
61
  ---
51
62
 
52
63
  ## 3. SDK keys
53
64
 
54
- Get a client SDK key from your A vs B project's Environments page.
65
+ Get the SDK key for the environment you want from your project: **app.avsb.cloud**, then **Settings**, then **Environments**. The format is `sdk_<environment>_<id>`, for example `sdk_production_ttqm0eaj4vth1krcb2xn`.
55
66
 
56
- Client SDK keys are safe to embed in browser bundles. Store them in your framework's public env variable:
67
+ There is one SDK key per environment, and "SDK key" is its only name. There is no separate client key and server key to choose between.
68
+
69
+ 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.
57
70
 
58
71
  ```
59
- VITE_AVSB_SDK_KEY=sdk-...
72
+ VITE_AVSB_SDK_KEY=sdk_production_...
60
73
  ```
61
74
 
75
+ The client checks the shape of the key when it is constructed. A key that does not look like an SDK key (a pasted dashboard URL, a truncated copy, a personal access token or a service token) logs one actionable error naming what it got and where the real key lives.
76
+
77
+ ---
78
+
79
+ ## 4. The whole type surface
80
+
81
+ Every export, with its real signature. The signatures name the package's own types, and Vue's. Both come from here:
82
+
62
83
  ```ts
63
- // main.ts
64
- const key = import.meta.env.VITE_AVSB_SDK_KEY
84
+ import type {
85
+ AvsbClientOptions,
86
+ AvsbContextValue,
87
+ AvsbPluginOptions,
88
+ AvsbProviderProps,
89
+ EvalContext,
90
+ Flag,
91
+ FlagDatafile,
92
+ FlagKeyInput,
93
+ TrackPayload,
94
+ UseAvsbStatusResult,
95
+ VueAvsbClient,
96
+ } from '@avsbhq/vue';
97
+ import type { ComputedRef, DefineComponent, InjectionKey, Plugin } from 'vue';
65
98
  ```
66
99
 
67
- Server SDK keys (used by `@avsbhq/node`) must never be exposed to the browser.
100
+ ```ts
101
+ // Provider and plugin
102
+ const AvsbProvider: DefineComponent<AvsbProviderProps>;
103
+ const AvsbPlugin: Plugin<AvsbPluginOptions>;
104
+
105
+ // Reading flags. `FlagKeyInput = string | Ref<string> | ComputedRef<string>`
106
+ function useFlag<T>(flagKey: FlagKeyInput, defaultValue: T): ComputedRef<Flag<T>>;
107
+ function useFlagValue<T>(flagKey: FlagKeyInput, defaultValue: T): ComputedRef<T>;
108
+ function useBoolFlag(flagKey: FlagKeyInput, defaultValue: boolean): ComputedRef<Flag<boolean>>;
109
+ function useStringFlag(flagKey: FlagKeyInput, defaultValue: string): ComputedRef<Flag<string>>;
110
+ function useNumberFlag(flagKey: FlagKeyInput, defaultValue: number): ComputedRef<Flag<number>>;
111
+ function useJsonFlag<T>(flagKey: FlagKeyInput, defaultValue: T): ComputedRef<Flag<T>>;
112
+ function useAllFlags(): ComputedRef<Record<string, Flag>>;
113
+
114
+ // Readiness and errors
115
+ function useFlagReady(): ComputedRef<boolean>;
116
+ function useAvsbStatus(): UseAvsbStatusResult;
117
+
118
+ // Identity
119
+ function useIdentify(): (context: EvalContext) => void;
120
+ function useAlias(): (previousContext: EvalContext, newContext: EvalContext) => void;
121
+ function useReset(): () => void;
122
+
123
+ // Events
124
+ function useTrack(): (eventKey: string, payload?: TrackPayload) => void;
125
+ function useExposure(flagKey: FlagKeyInput): void;
126
+ function useFlagSubscription(flagKey: FlagKeyInput, callback: (flag: Flag) => void): void;
127
+
128
+ // The client itself
129
+ function useAvsbClient(): VueAvsbClient;
130
+
131
+ // Context, for custom providers
132
+ const AvsbInjectionKey: InjectionKey<AvsbContextValue>;
133
+ ```
68
134
 
69
- ---
135
+ ### Provider props
70
136
 
71
- ## 4. Identity (identify, updateAttributes, alias, reset)
137
+ ```ts
138
+ type AvsbProviderProps = AvsbProviderModeAProps | AvsbProviderModeBProps;
139
+
140
+ interface AvsbProviderModeAProps {
141
+ /** SDK key for the environment you are targeting. */
142
+ sdkKey: string;
143
+ /** Evaluation context. Omit for an anonymous, persisted visitor id. */
144
+ context?: EvalContext;
145
+ /** A datafile fetched on the server, so the first paint has real values. */
146
+ bootstrap?: FlagDatafile;
147
+ /** Everything else AvsbClient accepts (polling, logging, caching). */
148
+ options?: Omit<AvsbClientOptions, 'sdkKey' | 'context' | 'bootstrap'>;
149
+ client?: never;
150
+ }
72
151
 
73
- ### Provider-level context
152
+ interface AvsbProviderModeBProps {
153
+ /** A client you built and own. The provider will not close it. */
154
+ client: VueAvsbClient;
155
+ sdkKey?: never;
156
+ }
157
+ ```
74
158
 
75
- Pass a context object to `<AvsbProvider>` at bootstrap. This is the evaluation context used for all flag reads until you call `identify`.
159
+ In templates the props are kebab-case and bound: `sdk-key`, `:context`, `:bootstrap`, `:options`, `:client`.
76
160
 
77
- ```vue
78
- <AvsbProvider :sdk-key="key" :context="{ kind: 'user', key: userId, plan: 'pro' }">
161
+ **Mode A** (an `sdk-key`) means the provider builds the client and closes it when it unmounts. **Mode B** (a `client`) means you own its lifetime and the provider never closes it. Passing neither throws at setup with a message naming both fixes, rather than rendering a tree where every flag is silently the default.
162
+
163
+ ### `Flag<T>`, the return type of every read
164
+
165
+ ```ts
166
+ interface Flag<T = unknown> {
167
+ /** The variation value, typed against the default you passed. */
168
+ readonly value: T;
169
+ /** Variation key, null when the default was served or the flag is unknown. */
170
+ readonly variationKey: string | null;
171
+ /** Why this value was produced. */
172
+ readonly source: EvaluationSource;
173
+ /** Rule that matched, null when none did. */
174
+ readonly ruleId: string | null;
175
+ readonly ruleType: RuleType | null;
176
+ /** Structured reasons for this decision. Never null; may be empty. */
177
+ readonly reasons: string[];
178
+ /** ms-epoch when this was evaluated. */
179
+ readonly evaluatedAt: number;
180
+ /** Microseconds spent in the evaluator. */
181
+ readonly durationMicros: number;
182
+ /** A real decision produced a truthy value. */
183
+ isEnabled(): boolean;
184
+ /** False for 'not_found' and for 'not_ready'. */
185
+ exists(): boolean;
186
+ }
187
+
188
+ type RuleType = 'targeted_delivery' | 'ab_test' | 'holdout' | 'bandit';
189
+
190
+ type EvaluationSource =
191
+ | 'datafileOverride'
192
+ | 'runtimeOverride'
193
+ | 'sticky'
194
+ | 'rule'
195
+ | 'holdout'
196
+ | 'bandit'
197
+ | 'default'
198
+ | 'not_found'
199
+ | 'disabled'
200
+ | 'not_ready';
79
201
  ```
80
202
 
81
- ### useIdentify
203
+ | `source` | Meaning | `isEnabled()` | `exists()` |
204
+ | ------------------ | ------------------------------------------------------------- | --------------- | ---------- |
205
+ | `datafileOverride` | A per-user override in the dashboard matched. | value-dependent | true |
206
+ | `runtimeOverride` | `setOverrideForUser` or `setGlobalOverride` matched. | value-dependent | true |
207
+ | `sticky` | A previously stored assignment was reused. | value-dependent | true |
208
+ | `rule` | A targeting rule or A/B rule matched. | value-dependent | true |
209
+ | `holdout` | The visitor is held out, so the holdout variation was served. | value-dependent | true |
210
+ | `bandit` | A bandit rule picked the variation. | value-dependent | true |
211
+ | `default` | The flag exists, nothing matched, default variation served. | false | true |
212
+ | `disabled` | The flag exists but is off in this environment. | false | true |
213
+ | `not_found` | The datafile loaded and has no such key. Check the name. | false | false |
214
+ | `not_ready` | No datafile yet. Gate on `useFlagReady()`. | false | false |
82
215
 
83
- Replace the current context at runtime (e.g. after sign-in):
216
+ "value-dependent" means `isEnabled()` is `Boolean(flag.value)`.
217
+
218
+ ### `UseAvsbStatusResult`
84
219
 
85
220
  ```ts
86
- import { useIdentify } from '@avsbhq/vue'
221
+ interface UseAvsbStatusResult {
222
+ status: ComputedRef<'loading' | 'ready' | 'error'>;
223
+ error: ComputedRef<Error | undefined>;
224
+ degraded: ComputedRef<boolean>;
225
+ }
226
+ ```
87
227
 
88
- const identify = useIdentify()
228
+ ### Typed flag keys
89
229
 
90
- function onSignIn(user: User) {
91
- identify({ kind: 'user', key: user.id, email: user.email, plan: user.plan })
92
- }
230
+ `FlagKeyInput` is built from `string` until you generate your keys. Generate
231
+ them and it is built from the union of this project's real flag keys, so a typo
232
+ is a compile error and your editor completes the list:
233
+
234
+ ```bash
235
+ npx avsb codegen --output src/generated/flags.ts
93
236
  ```
94
237
 
95
- The function is stable; it will not change reference between renders.
238
+ The generated file declares your flags twice on purpose: an importable
239
+ `FlagValues` interface for payload types, and a `declare global` block that
240
+ teaches every composable in this package which keys exist. Its important parts:
96
241
 
97
- ### useAlias
242
+ ```text
243
+ // AUTO-GENERATED by @avsbhq/cli codegen. Do not edit by hand.
244
+
245
+ export interface FlagValues {
246
+ 'checkout-v2': boolean
247
+ 'hero-copy': 'control' | 'variant-a'
248
+ 'theme': { primary: string }
249
+ }
98
250
 
99
- Record that an anonymous visitor and an identified user are the same person. Call this once at sign-in:
251
+ declare global {
252
+ interface AvsbFlags {
253
+ 'checkout-v2': boolean
254
+ 'hero-copy': 'control' | 'variant-a'
255
+ 'theme': { primary: string }
256
+ }
257
+ }
258
+ ```
100
259
 
101
260
  ```ts
102
- import { useAlias } from '@avsbhq/vue'
261
+ import { useBoolFlag, useJsonFlag } from '@avsbhq/vue';
262
+ import type { FlagValues } from './generated/flags';
103
263
 
104
- const alias = useAlias()
264
+ const checkout = useBoolFlag('checkout-v2', false);
105
265
 
106
- async function onSignIn(anonKey: string, userId: string) {
107
- await alias(
108
- { kind: 'user', key: anonKey },
109
- { kind: 'user', key: userId }
110
- )
111
- }
266
+ // A JSON flag types its payload from the generated table:
267
+ const theme = useJsonFlag<FlagValues['theme']>('theme', { primary: '#111' });
268
+
269
+ // Once the generated file exists, this line stops compiling:
270
+ // 'chekcout-v2' is not a flag key.
271
+ const typo = useBoolFlag('chekcout-v2', false);
112
272
  ```
113
273
 
114
- ### reset
274
+ One real consequence for reactive keys: a `Ref<string>` no longer satisfies
275
+ `FlagKeyInput` once your keys are generated, because that ref could hold any
276
+ string. Declare it as `Ref<AvsbFlagKey>` (or `ComputedRef<AvsbFlagKey>`) and it
277
+ fits again.
115
278
 
116
- Call `client.reset()` via `useAvsbClient()` to clear the current identity and return to the anonymous state.
279
+ Nothing changes for a project that never runs `codegen`: with no generated file
280
+ the table is empty, so `FlagKeyInput` accepts any string exactly as it does
281
+ today, and every call you have already written compiles unchanged. For a key
282
+ computed at runtime, widen deliberately with `key as AvsbFlagKey` (that type is
283
+ exported from `@avsbhq/core`).
117
284
 
118
285
  ---
119
286
 
120
- ## 5. Multi-context
287
+ ## 5. Why `.value.value`
121
288
 
122
- A vs B supports multi-context evaluation: evaluating a flag for both a user and an organisation simultaneously:
289
+ `useFlag` returns a `ComputedRef<Flag<T>>`. Vue refs are unwrapped with `.value`, and the flag's own field is also called `value`, so in `<script setup>` you write:
123
290
 
124
291
  ```ts
125
- identify({
126
- kind: 'multi',
127
- user: { kind: 'user', key: 'u_123', plan: 'pro' },
128
- organization: { kind: 'organization', key: 'org_456', tier: 'enterprise' },
129
- })
292
+ const checkout = useFlag('new-checkout-flow', false);
293
+
294
+ checkout.value.value; // boolean (ref unwrap, then Flag.value)
295
+ checkout.value.isEnabled(); // boolean
296
+ checkout.value.variationKey; // string | null
130
297
  ```
131
298
 
132
- ---
299
+ In a template Vue unwraps the ref for you, so it is one `.value`:
133
300
 
134
- ## 6. Reading flags
301
+ ```vue
302
+ <template>
303
+ <NewCheckout v-if="checkout.value" />
304
+ </template>
305
+ ```
135
306
 
136
- ### useFlag (full Flag object)
307
+ This is a real papercut and we are not going to hide it behind a rename: `Flag.value` is the same field in seven SDKs, and renaming it here would make the Vue docs disagree with every other page and every code sample.
308
+
309
+ Two ways to make it read better:
137
310
 
138
311
  ```ts
139
- import { useFlag } from '@avsbhq/vue'
312
+ import { computed } from 'vue';
140
313
 
141
- const checkoutFlag = useFlag('new-checkout-flow', false)
142
- // checkoutFlag is ComputedRef<Flag<boolean>>
314
+ // 1. Ask for the value instead of the Flag
315
+ const showNewCheckout = useFlagValue('new-checkout-flow', false);
316
+ showNewCheckout.value; // boolean, one .value everywhere
143
317
 
144
- // In template:
145
- // checkoutFlag.value.value → the boolean value
146
- // checkoutFlag.value.isEnabled() → true if rule-served AND truthy
147
- // checkoutFlag.value.variationKey → 'on' | 'off' | ...
148
- // checkoutFlag.value.source → 'rule' | 'override' | 'not_found' | ...
318
+ // 2. Destructure with toRefs-style naming at the top of setup
319
+ const checkoutFlag = useFlag('new-checkout-flow', false);
320
+ const isNewCheckout = computed(() => checkoutFlag.value.isEnabled());
149
321
  ```
150
322
 
151
- A default value is always required. It is returned until the SDK is ready and whenever the flag is not found.
323
+ ---
324
+
325
+ ## 6. Identity
326
+
327
+ ```vue
328
+ <AvsbProvider :sdk-key="sdkKey" :context="{ kind: 'user', key: userId, plan: 'pro' }">
329
+ ```
152
330
 
153
- ### useFlagValue (raw value shortcut)
331
+ Anonymous visitors get a persisted id (localStorage, with a cookie fallback), so a returning visitor buckets into the same variation instead of being re-randomised on every load. If the A vs B web snippet is on the same page, the SDK adopts its visitor id so flag exposures and web experiment exposures join to one visitor.
154
332
 
155
333
  ```ts
156
- import { useFlagValue } from '@avsbhq/vue'
334
+ import { useAvsbClient, useIdentify, useAlias, useReset } from '@avsbhq/vue';
157
335
 
158
- const showBanner = useFlagValue('promo-banner', false)
159
- // showBanner is ComputedRef<boolean>
336
+ const client = useAvsbClient();
337
+ const identify = useIdentify();
338
+ const alias = useAlias();
339
+ const reset = useReset();
340
+
341
+ function onSignIn(user: { id: string; email: string; plan: string }): void {
342
+ const context = client.getContext();
343
+ const anonymousKey: string = 'key' in context ? String(context['key']) : '';
344
+
345
+ // Same person, two sessions: this is what lets results stitch them.
346
+ alias({ kind: 'user', key: anonymousKey }, { kind: 'user', key: user.id });
347
+
348
+ // From here on, flags evaluate for the signed-in user.
349
+ identify({ kind: 'user', key: user.id, email: user.email, plan: user.plan });
350
+ }
351
+
352
+ function onSignOut(): void {
353
+ reset(); // new anonymous identity, runtime overrides cleared
354
+ }
160
355
  ```
161
356
 
162
- ### Typed variants
357
+ `alias` is synchronous: it queues one event and returns. There is nothing to await, and it does not rewrite assignments already made. To carry bucketing across a login, evaluate with the same context key on both sides.
163
358
 
164
- Use typed composables when you want a runtime type-safety check against the datafile's declared flag type:
359
+ ### Multi-context
165
360
 
166
361
  ```ts
167
- import { useBoolFlag, useStringFlag, useNumberFlag, useJsonFlag } from '@avsbhq/vue'
168
-
169
- const darkMode = useBoolFlag('dark-mode', false)
170
- const theme = useStringFlag('theme', 'light')
171
- const maxItems = useNumberFlag('max-items', 10)
172
- const config = useJsonFlag<{ timeout: number }>('api-config', { timeout: 5000 })
362
+ identify({
363
+ kind: 'multi',
364
+ user: { kind: 'user', key: 'u_123', plan: 'pro' },
365
+ organization: { kind: 'organization', key: 'org_456', tier: 'enterprise' },
366
+ });
173
367
  ```
174
368
 
175
- All return `ComputedRef<Flag<T>>`.
369
+ A rule can bucket on `user.key` while matching an audience condition on `organization.tier`.
176
370
 
177
- ### Reactive flag key
371
+ ---
178
372
 
179
- `useFlag` (and `useFlagValue`) accept a `Ref<string>` or `ComputedRef<string>` as the key, and the subscription updates automatically when the key changes:
373
+ ## 7. Reading flags
180
374
 
181
375
  ```ts
182
- const selectedFlag = ref('feature-a')
183
- const flag = useFlag(selectedFlag, false)
376
+ import { useFlag, useBoolFlag, useJsonFlag } from '@avsbhq/vue';
377
+ import type { Flag } from '@avsbhq/vue';
378
+
379
+ const darkMode = useBoolFlag('dark-mode', false);
380
+
381
+ interface ApiConfig {
382
+ timeout: number;
383
+ retries: number;
384
+ }
385
+ const config = useJsonFlag<ApiConfig>('api-config', { timeout: 5000, retries: 3 });
386
+ const timeout: number = config.value.value.timeout;
184
387
  ```
185
388
 
186
- ### useAllFlags (all evaluated flags)
389
+ The typed reads check the value against the type the platform declared for that flag. **On a mismatch nothing throws**: the SDK logs one warning naming the flag and the getter, then returns your `defaultValue` with `source: 'not_found'`. `useJsonFlag<T>` does not validate the shape of `T` at runtime, only that the flag is declared as JSON; validate the payload yourself if it crosses a trust boundary.
390
+
391
+ A default value is always required, and it is what you get before the SDK is ready or when the key is unknown.
392
+
393
+ ### Reactive keys
187
394
 
188
395
  ```ts
189
- import { useAllFlags } from '@avsbhq/vue'
396
+ import { ref } from 'vue';
190
397
 
191
- const flags = useAllFlags()
192
- // flags is ComputedRef<Record<string, Flag>>
193
- // Re-computes on any flagChange event.
398
+ const selected = ref('feature-a');
399
+ const flag = useFlag(selected, false); // re-subscribes when `selected` changes
194
400
  ```
195
401
 
196
- Prefer individual `useFlag` composables in most components. `useAllFlags` triggers on any flag change and is best suited for debug panels.
402
+ ### Every flag at once
403
+
404
+ ```ts
405
+ const flags = useAllFlags(); // ComputedRef<Record<string, Flag>>
406
+ ```
407
+
408
+ Re-computes on any flag change, so prefer `useFlag` in normal components. Exposures are suppressed and the SDK returns the same object between changes, so this is cheap to read on every render.
197
409
 
198
410
  ---
199
411
 
200
- ## 7. Tracking events
412
+ ## 8. Exposures: what lands in your results
201
413
 
202
- ```ts
203
- import { useTrack } from '@avsbhq/vue'
414
+ Reads during render never record an exposure. That is deliberate: an exposure means "this visitor saw this decision", and Vue can re-render a component any number of times for reasons that have nothing to do with the visitor.
204
415
 
205
- const track = useTrack()
416
+ ```ts
417
+ import { useExposure } from '@avsbhq/vue';
206
418
 
207
- function onClick() {
208
- track('button_clicked', { location: 'header', variant: 'A' })
209
- }
419
+ // In the component that actually shows the variation:
420
+ useExposure('new-checkout-flow');
210
421
  ```
211
422
 
212
- The function is defined once in setup, so it is safe to use as an event handler without wrapping in a closure.
423
+ `useExposure` fires on mount, once, or as soon as the SDK becomes ready if it is not yet. It is also the piece that makes server-rendered variants visible in results: if a value arrived as a prop from the server, call `useExposure` where it is rendered.
213
424
 
214
- ---
425
+ Only decisions that belong in an experiment (A/B rules, holdouts, bandits) produce an exposure event; a plain rollout has nothing to record.
215
426
 
216
- ## 8. Error handling
427
+ For side effects that are not rendering:
217
428
 
218
- ### Provider-level error state
429
+ ```ts
430
+ useFlagSubscription('new-checkout-flow', (flag) => {
431
+ analytics.track('checkout_variant_assigned', { variationKey: flag.variationKey });
432
+ });
433
+ ```
434
+
435
+ ---
219
436
 
220
- `<AvsbProvider>` exposes a `status` that transitions to `'error'` if `onReady()` rejects or if the client emits an `'error'` event. Use `useFlagReady` to gate rendering:
437
+ ## 9. Readiness and failure
221
438
 
222
439
  ```vue
223
440
  <script setup lang="ts">
224
- import { useFlagReady } from '@avsbhq/vue'
225
- const ready = useFlagReady()
441
+ import { useAvsbStatus } from '@avsbhq/vue';
442
+
443
+ const { status, error, degraded } = useAvsbStatus();
226
444
  </script>
227
445
 
228
446
  <template>
229
- <div v-if="!ready">Loading flags...</div>
230
- <MyApp v-else />
447
+ <SkeletonApp v-if="status === 'loading'" />
448
+ <template v-else>
449
+ <StaleDataNotice v-if="degraded" :message="error?.message" />
450
+ <MyApp />
451
+ </template>
231
452
  </template>
232
453
  ```
233
454
 
234
- ### Custom logger
455
+ - `'loading'`: no datafile yet and nothing cached.
456
+ - `'ready'`: flags answer. A **degraded** client is ready: a refresh failed while a cached datafile is being served, so values work and may be stale. `error` says why.
457
+ - `'error'`: nothing could be loaded. Every flag returns the default you passed, and `error.message` names the HTTP status, the URL tried, and the fix.
235
458
 
236
- Pass a logger to suppress or redirect SDK output:
459
+ The SDK's default logger writes to the console at `warn` level in development and is silent in production. Override it per client:
237
460
 
238
461
  ```ts
239
- app.use(AvsbPlugin, {
240
- sdkKey: '...',
241
- logger: {
242
- debug: (msg, ...args) => myLogger.debug(msg, ...args),
243
- warn: (msg, ...args) => myLogger.warn(msg, ...args),
244
- error: (msg, ...args) => myLogger.error(msg, ...args),
245
- },
246
- })
462
+ const options = { logLevel: 'silent' } as const;
463
+ ```
464
+
465
+ ```vue
466
+ <AvsbProvider :sdk-key="sdkKey" :options="{ logLevel: 'debug' }">
247
467
  ```
248
468
 
249
469
  ---
250
470
 
251
- ## 9. SSR / Nuxt
471
+ ## 10. Server rendering
252
472
 
253
- For SSR, pre-fetch the datafile server-side and pass it as the `bootstrap` option to skip the initial network round-trip on the client:
473
+ Fetch the datafile on the server and hand it to the provider, so the first render has real values instead of defaults:
254
474
 
255
475
  ```ts
256
- // server.ts
257
- import { AvsbClient } from '@avsbhq/browser'
476
+ // Server
477
+ import { fetchDatafile } from '@avsbhq/browser/server';
478
+ import type { FlagDatafile } from '@avsbhq/vue';
479
+
480
+ const sdkKey: string = process.env['AVSB_SDK_KEY'] ?? '';
258
481
 
259
- const client = new AvsbClient({ sdkKey: '...', bootstrap: serverDatafile })
482
+ const datafile: FlagDatafile | null = await fetchDatafile(sdkKey, { timeout: 2000 });
260
483
  ```
261
484
 
262
485
  ```vue
263
- <!-- App.vue, client side -->
264
- <AvsbProvider :sdk-key="sdkKey" :bootstrap="bootstrapDatafile">
265
- <NuxtPage />
486
+ <!-- Client -->
487
+ <AvsbProvider :sdk-key="sdkKey" :bootstrap="datafileFromServer" :context="userContext">
488
+ <App />
266
489
  </AvsbProvider>
267
490
  ```
268
491
 
269
- Flag composables return a `not_found` sentinel during the hydration window so SSR output matches the client's initial render.
492
+ With a bootstrap the client is ready on the first pass: `useFlagReady()` is already `true`, no loading state renders, and the values match what the server rendered. Serialise the datafile into the page the way your framework escapes JSON.
493
+
494
+ For Nuxt apps, use the @avsbhq/nuxt module, which wires this plugin, SSR bootstrap, and auto-imports for you. Outside Nuxt, the pattern above is the whole SSR story for Vue, and it is honest: without a bootstrap the first render shows your defaults.
270
495
 
271
496
  ---
272
497
 
273
- ## 10. Graceful shutdown
498
+ ## 11. Pinia (optional)
274
499
 
275
- When using `<AvsbProvider>` in Mode A (sdkKey prop), the client is closed automatically on component unmount. This flushes any queued events.
500
+ ```ts
501
+ import { useAvsbStore } from '@avsbhq/vue/pinia';
276
502
 
277
- When using Mode B (passing a pre-built `client` prop), you own the lifecycle:
503
+ const avsb = useAvsbStore();
504
+
505
+ avsb.status; // 'loading' | 'ready' | 'error'
506
+ avsb.degraded; // boolean
507
+ avsb.flags; // Record<string, Flag>, refreshed on any flag change
508
+ avsb.identify({ kind: 'user', key: 'u_1' });
509
+ avsb.track('checkout_started', { value: 99 });
510
+ avsb.reset();
511
+ ```
512
+
513
+ The store reflects the provider; it never creates a client. The first component that calls `useAvsbStore()` binds it, so that call has to happen somewhere under `<AvsbProvider>` (or in an app that installed `AvsbPlugin`).
514
+
515
+ This lives on its own entry point on purpose. Importing it from the main barrel used to pull `pinia` into every Vue app whether or not it used Pinia, which broke module resolution for everyone else.
516
+
517
+ ---
518
+
519
+ ## 12. Plugin form
278
520
 
279
521
  ```ts
280
- onUnmounted(async () => {
281
- await client.flush()
282
- await client.close()
283
- })
522
+ import { createApp } from 'vue';
523
+ import { AvsbPlugin } from '@avsbhq/vue';
524
+ import App from './App.vue';
525
+
526
+ declare const userId: string;
527
+
528
+ const app = createApp(App);
529
+
530
+ app.use(AvsbPlugin, {
531
+ sdkKey: import.meta.env['VITE_AVSB_SDK_KEY'] as string,
532
+ context: { kind: 'user', key: userId },
533
+ });
534
+
535
+ app.mount('#app');
284
536
  ```
285
537
 
286
- The `AvsbPlugin` (Mode A) proxies `app.unmount` to close the client when the Vue app tears down.
538
+ Same semantics as the component, provided at app level. In Mode A the plugin closes the client when the app unmounts, which flushes whatever events are queued.
539
+
540
+ ### One provider, please
541
+
542
+ Nesting a second `<AvsbProvider>` inside another shadows the outer one for everything below it: two clients, two visitor ids, two event queues, and identify calls that only reach one of them. The provider logs a warning in development when it detects this. Mount one provider at the root unless you deliberately want an isolated subtree.
287
543
 
288
544
  ---
289
545
 
290
- ## 11. Testing
546
+ ## 13. Graceful shutdown
291
547
 
292
- Inject a mock context directly via `provide`; no need to mount the full provider:
548
+ Mode A closes the client for you on unmount, which flushes queued events. In Mode B you own it:
293
549
 
294
550
  ```ts
295
- import { defineComponent, h, provide } from 'vue'
296
- import { mount } from '@vue/test-utils'
297
- import { vi } from 'vitest'
298
- import { AvsbInjectionKey } from '@avsbhq/vue'
299
- import { notFoundFlag, createFlag } from '@avsbhq/core'
300
-
301
- function makeMockClient() {
302
- return {
303
- subscribe: vi.fn((_key, cb) => { /* store cb */ return () => {} }),
304
- getSnapshot: vi.fn((key, def) => notFoundFlag(def, 'no data')),
305
- on: vi.fn(() => () => {}),
306
- onReady: vi.fn(() => Promise.resolve()),
307
- close: vi.fn(() => Promise.resolve()),
308
- track: vi.fn(),
309
- identify: vi.fn(),
310
- alias: vi.fn(() => Promise.resolve()),
311
- reset: vi.fn(),
312
- }
313
- }
551
+ import { onUnmounted } from 'vue';
314
552
 
315
- const Wrapper = defineComponent({
316
- setup() {
317
- provide(AvsbInjectionKey, {
318
- client: makeMockClient(),
319
- status: 'ready',
320
- })
321
- },
322
- render() {
323
- return h(MyComponent)
324
- },
325
- })
553
+ onUnmounted(async () => {
554
+ await client.close(); // flushes internally
555
+ });
556
+ ```
326
557
 
327
- const wrapper = mount(Wrapper)
558
+ ---
559
+
560
+ ## 14. Testing
561
+
562
+ ```ts
563
+ import { mount } from '@vue/test-utils';
564
+ import { AvsbTestProvider } from '@avsbhq/vue/testing';
565
+ import CheckoutButton from './CheckoutButton.vue';
566
+
567
+ const wrapper = mount(AvsbTestProvider, {
568
+ props: { flags: { 'checkout-v2': true } },
569
+ slots: { default: CheckoutButton },
570
+ });
328
571
  ```
329
572
 
330
- Use `createFlag()` from `@avsbhq/core` to build typed flag fixtures:
573
+ `AvsbTestProvider` is the real `<AvsbProvider>` in Mode B around a real `AvsbClient` whose datafile is built from your flags map, with the network, the cache, and logging switched off. Your components use the same composables, the same evaluator, and the same exposure rules they use in production.
574
+
575
+ Each entry in `flags` becomes a flag that is fully rolled out, so `isEnabled()` is true for truthy values and reads record exposures exactly as an A/B rule would.
576
+
577
+ Drive the client directly when that is simpler:
578
+
579
+ ```ts
580
+ import { createTestClient, createTestDatafile } from '@avsbhq/vue/testing';
581
+
582
+ const client = createTestClient({
583
+ flags: { 'checkout-v2': true },
584
+ context: { kind: 'user', key: 'u_1' },
585
+ });
586
+
587
+ expect(client.getBoolFlag('checkout-v2', false).isEnabled()).toBe(true);
588
+
589
+ // Publish a new datafile and watch subscribers wake up.
590
+ client.applyDatafileBootstrap(
591
+ createTestDatafile({ 'checkout-v2': false }, { publishedAt: '2024-06-01T00:00:00.000Z' }),
592
+ );
593
+ ```
331
594
 
332
595
  ```ts
333
- import { createFlag } from '@avsbhq/core'
596
+ import type { AvsbClient } from '@avsbhq/browser';
597
+ import type { FlagDatafileEntry } from '@avsbhq/vue';
334
598
 
335
- const FLAG_ON = createFlag<boolean>({
336
- value: true,
337
- variationKey: 'on',
338
- source: 'rule',
339
- ruleId: 'r1',
340
- ruleType: 'ab_test',
341
- reasons: ['matched rule'],
342
- })
599
+ /** Flag key to the value it serves. */
600
+ type TestFlags = Record<string, boolean | string | number | object | null>;
601
+
602
+ interface TestClientOptions {
603
+ flags?: TestFlags;
604
+ context?: EvalContext;
605
+ /** A full datafile, for when a map of values is not expressive enough. */
606
+ datafile?: FlagDatafile;
607
+ }
608
+
609
+ function createTestClient(options?: TestClientOptions): AvsbClient;
610
+ function createTestDatafile(
611
+ flags?: TestFlags,
612
+ options?: {
613
+ sdkKey?: string;
614
+ environmentKey?: string;
615
+ publishedAt?: string;
616
+ entries?: FlagDatafileEntry[];
617
+ },
618
+ ): FlagDatafile;
343
619
  ```
344
620
 
621
+ Call `client.close()` at the end of a test that tracked events, so the flush timer does not outlive it.
622
+
345
623
  ---
346
624
 
347
- ## 12. Migration from LaunchDarkly
625
+ ## 15. Migration from LaunchDarkly
626
+
627
+ | LaunchDarkly | `@avsbhq/vue` |
628
+ | -------------------------------------- | -------------------------------------------------- |
629
+ | `LDPlugin` / `asyncWithLDProvider` | `<AvsbProvider sdk-key>` or `app.use(AvsbPlugin)` |
630
+ | `ldClient.waitForInitialization()` | `useFlagReady()` or `useAvsbStatus()` |
631
+ | `useFlags()` | `useAllFlags()` |
632
+ | `ldClient.variation('key', false)` | `useBoolFlag('key', false).value.value` |
633
+ | `ldClient.variationDetail('key', d)` | `useFlag('key', d)` |
634
+ | `ldClient.identify(context)` | `useIdentify()(context)` |
635
+ | `ldClient.track('event', data, value)` | `useTrack()('event', { value, properties: data })` |
636
+ | `ldClient.on('change:key', cb)` | `useFlagSubscription('key', cb)` |
348
637
 
349
- | LaunchDarkly (React SDK) | @avsbhq/vue equivalent |
350
- |---------------------------------------|----------------------------------------------------|
351
- | `<LDProvider clientSideID="...">` | `<AvsbProvider sdkKey="...">` |
352
- | `useLDClient()` | `useAvsbClient()` |
353
- | `useFlags()` | `useAllFlags()` |
354
- | `useLDFlag('key', default)` | `useFlag('key', default).value` |
355
- | `ldClient.identify(context)` | `useIdentify()(context)` |
356
- | `ldClient.track('event')` | `useTrack()('event')` |
357
- | `ldClient.variation('key', default)` | `useAvsbClient()?.getFlag('key', default)` |
638
+ Differences worth knowing:
358
639
 
359
- Key differences:
640
+ - Every read returns a `Flag<T>` object, not a raw value, so you get the decision metadata without a second call.
641
+ - Every read requires an explicit default. There is no untyped variation call.
642
+ - Multi-context is first class: one `EvalContext` with kinds, no wrapper type.
643
+ - Reads during render record no exposure. Call `useExposure` where the visitor actually sees the decision.
644
+
645
+ ---
646
+
647
+ ## 16. Set this up with your AI assistant
648
+
649
+ Paste this into Claude Code, Cursor, or any coding assistant:
650
+
651
+ ```text
652
+ Set up A vs B feature flags in this Vue project.
653
+
654
+ 1. Run: npx @avsbhq/cli init
655
+ Use my saved CLI login: do not ask me for a token and do not put one in any file.
656
+ It detects Vue, writes the SDK key into the environment file this project's
657
+ bundler reads (VITE_AVSB_SDK_KEY for Vite), and writes an example component.
658
+ 2. Install @avsbhq/vue with this project's package manager.
659
+ 3. Mount <AvsbProvider> once at the root with that key and a context of
660
+ { kind: 'user', key: <your user id> }, then read the flag the example names.
661
+ 4. Every getter returns a ref to a Flag object, so keep the ref and read
662
+ flag.value.value or call flag.value.isEnabled(), and always pass a fallback.
663
+ Reading a flag records nothing: call useExposure(key) where the variation is
664
+ shown.
665
+ 5. Then run: npx @avsbhq/cli codegen
666
+
667
+ Done looks like: the app starts, the flag reads without throwing, and avsb init
668
+ prints the line confirming it saw the first check-in.
669
+ ```
360
670
 
361
- - `useFlag` returns a `Flag<T>` object, not a raw `T`. Access `.value` for the primitive or `.isEnabled()` for a boolean gate. This gives you the evaluation metadata (source, variationKey, reasons) without a second call.
362
- - All `getFlag` variants require an explicit `defaultValue`. There is no untyped `.variation()`.
363
- - Multi-context is a first-class concept, with no adapter required.
671
+ `avsb init` ends by waiting for your app's first check-in and printing what it
672
+ saw, so the terminal tells you it works rather than the dashboard.