@avsbhq/vue 1.0.2 → 1.1.1
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/LICENSE +21 -0
- package/README.md +491 -182
- package/dist/chunk-4XBADQFO.js +21 -0
- package/dist/chunk-FUADAPSW.js +140 -0
- package/dist/clientInterface-BMlQ7Uhe.d.cts +69 -0
- package/dist/clientInterface-BMlQ7Uhe.d.ts +69 -0
- package/dist/index.cjs +283 -350
- package/dist/index.d.cts +202 -172
- package/dist/index.d.ts +202 -172
- package/dist/index.js +177 -413
- package/dist/pinia.cjs +74 -0
- package/dist/pinia.d.cts +23 -0
- package/dist/pinia.d.ts +23 -0
- package/dist/pinia.js +38 -0
- package/dist/testing.cjs +279 -0
- package/dist/testing.d.cts +50 -0
- package/dist/testing.d.ts +50 -0
- package/dist/testing.js +120 -0
- package/dist/types-DECMgjjk.d.cts +34 -0
- package/dist/types-Di6G-dj0.d.ts +34 -0
- package/package.json +69 -19
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
40
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
+
In templates the props are kebab-case and bound: `sdk-key`, `:context`, `:bootstrap`, `:options`, `:client`.
|
|
76
160
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
"value-dependent" means `isEnabled()` is `Boolean(flag.value)`.
|
|
217
|
+
|
|
218
|
+
### `UseAvsbStatusResult`
|
|
84
219
|
|
|
85
220
|
```ts
|
|
86
|
-
|
|
221
|
+
interface UseAvsbStatusResult {
|
|
222
|
+
status: ComputedRef<'loading' | 'ready' | 'error'>;
|
|
223
|
+
error: ComputedRef<Error | undefined>;
|
|
224
|
+
degraded: ComputedRef<boolean>;
|
|
225
|
+
}
|
|
226
|
+
```
|
|
87
227
|
|
|
88
|
-
|
|
228
|
+
### Typed flag keys
|
|
89
229
|
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
261
|
+
import { useBoolFlag, useJsonFlag } from '@avsbhq/vue';
|
|
262
|
+
import type { FlagValues } from './generated/flags';
|
|
103
263
|
|
|
104
|
-
const
|
|
264
|
+
const checkout = useBoolFlag('checkout-v2', false);
|
|
105
265
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
287
|
+
## 5. Why `.value.value`
|
|
121
288
|
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
301
|
+
```vue
|
|
302
|
+
<template>
|
|
303
|
+
<NewCheckout v-if="checkout.value" />
|
|
304
|
+
</template>
|
|
305
|
+
```
|
|
135
306
|
|
|
136
|
-
|
|
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 {
|
|
312
|
+
import { computed } from 'vue';
|
|
140
313
|
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## 6. Identity
|
|
326
|
+
|
|
327
|
+
```vue
|
|
328
|
+
<AvsbProvider :sdk-key="sdkKey" :context="{ kind: 'user', key: userId, plan: 'pro' }">
|
|
329
|
+
```
|
|
152
330
|
|
|
153
|
-
|
|
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 {
|
|
334
|
+
import { useAvsbClient, useIdentify, useAlias, useReset } from '@avsbhq/vue';
|
|
157
335
|
|
|
158
|
-
const
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
+
### Multi-context
|
|
165
360
|
|
|
166
361
|
```ts
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
369
|
+
A rule can bucket on `user.key` while matching an audience condition on `organization.tier`.
|
|
176
370
|
|
|
177
|
-
|
|
371
|
+
---
|
|
178
372
|
|
|
179
|
-
|
|
373
|
+
## 7. Reading flags
|
|
180
374
|
|
|
181
375
|
```ts
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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 {
|
|
396
|
+
import { ref } from 'vue';
|
|
190
397
|
|
|
191
|
-
const
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
412
|
+
## 8. Exposures: what lands in your results
|
|
201
413
|
|
|
202
|
-
|
|
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
|
-
|
|
416
|
+
```ts
|
|
417
|
+
import { useExposure } from '@avsbhq/vue';
|
|
206
418
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
419
|
+
// In the component that actually shows the variation:
|
|
420
|
+
useExposure('new-checkout-flow');
|
|
210
421
|
```
|
|
211
422
|
|
|
212
|
-
|
|
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
|
-
|
|
427
|
+
For side effects that are not rendering:
|
|
217
428
|
|
|
218
|
-
|
|
429
|
+
```ts
|
|
430
|
+
useFlagSubscription('new-checkout-flow', (flag) => {
|
|
431
|
+
analytics.track('checkout_variant_assigned', { variationKey: flag.variationKey });
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
---
|
|
219
436
|
|
|
220
|
-
|
|
437
|
+
## 9. Readiness and failure
|
|
221
438
|
|
|
222
439
|
```vue
|
|
223
440
|
<script setup lang="ts">
|
|
224
|
-
import {
|
|
225
|
-
|
|
441
|
+
import { useAvsbStatus } from '@avsbhq/vue';
|
|
442
|
+
|
|
443
|
+
const { status, error, degraded } = useAvsbStatus();
|
|
226
444
|
</script>
|
|
227
445
|
|
|
228
446
|
<template>
|
|
229
|
-
<
|
|
230
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
##
|
|
471
|
+
## 10. Server rendering
|
|
252
472
|
|
|
253
|
-
|
|
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
|
-
//
|
|
257
|
-
import {
|
|
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
|
|
482
|
+
const datafile: FlagDatafile | null = await fetchDatafile(sdkKey, { timeout: 2000 });
|
|
260
483
|
```
|
|
261
484
|
|
|
262
485
|
```vue
|
|
263
|
-
<!--
|
|
264
|
-
<AvsbProvider :sdk-key="sdkKey" :bootstrap="
|
|
265
|
-
<
|
|
486
|
+
<!-- Client -->
|
|
487
|
+
<AvsbProvider :sdk-key="sdkKey" :bootstrap="datafileFromServer" :context="userContext">
|
|
488
|
+
<App />
|
|
266
489
|
</AvsbProvider>
|
|
267
490
|
```
|
|
268
491
|
|
|
269
|
-
|
|
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
|
-
##
|
|
498
|
+
## 11. Pinia (optional)
|
|
274
499
|
|
|
275
|
-
|
|
500
|
+
```ts
|
|
501
|
+
import { useAvsbStore } from '@avsbhq/vue/pinia';
|
|
276
502
|
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
546
|
+
## 13. Graceful shutdown
|
|
291
547
|
|
|
292
|
-
|
|
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 {
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
596
|
+
import type { AvsbClient } from '@avsbhq/browser';
|
|
597
|
+
import type { FlagDatafileEntry } from '@avsbhq/vue';
|
|
334
598
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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.
|