@avsbhq/node 1.6.2 → 1.7.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
@@ -1,8 +1,8 @@
1
1
  # @avsbhq/node
2
2
 
3
- Node.js server-side SDK for the [A vs B](https://app.avsb.cloud) platform.
3
+ Node.js server SDK for [A vs B](https://app.avsb.cloud).
4
4
 
5
- Evaluate feature flags, bucket users consistently, and track conversion events on the server. Includes Express and Fastify middleware, real-time datafile streaming over SSE, pluggable sticky bucketing (in-memory and Redis), multi-context evaluation, and per-request decision logs.
5
+ Evaluate feature flags, bucket users consistently, and track conversions on the server. Includes Express and Fastify middleware, real-time datafile streaming over SSE (on by default), pluggable sticky bucketing (in-memory and Redis), multi-context evaluation, and per-request decision logs.
6
6
 
7
7
  ---
8
8
 
@@ -12,62 +12,124 @@ Evaluate feature flags, bucket users consistently, and track conversion events o
12
12
  npm install @avsbhq/node
13
13
  ```
14
14
 
15
- Requires Node.js 18 or later.
15
+ Requires Node.js 18 or later. No mandatory peer dependencies.
16
16
 
17
- No mandatory peer dependencies. Express and Fastify middleware are included but only activated when the corresponding framework is present. Install `@avsbhq/utils` for additional middleware (Hono, Koa, Lambda, Cloudflare Workers), OpenFeature integration, and warehouse-native decision sinks.
17
+ Install `@avsbhq/utils` as well for the other framework adapters (Hono, Koa, Next, Lambda, Cloudflare Workers), the OpenFeature provider, and the warehouse decision sinks.
18
18
 
19
19
  ---
20
20
 
21
21
  ## 2. Quickstart
22
22
 
23
23
  ```ts
24
- import { AvsbServer } from '@avsbhq/node'
24
+ import { AvsbServer } from '@avsbhq/node';
25
25
 
26
- const server = new AvsbServer({
27
- sdkKey: process.env.AVSB_SDK_KEY!,
28
- })
26
+ const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! });
29
27
 
30
- const result = await server.onReady()
28
+ const result = await server.onReady();
31
29
  if (!result.success) {
32
- process.exit(1) // or degrade gracefully: result.degraded === true means defaults are safe
30
+ // Flags serve the defaults you pass until a datafile arrives, and polling
31
+ // continues in the background. Survivable, not fatal.
32
+ console.error('AvsB init failed', result.error);
33
33
  }
34
34
 
35
- // Evaluate per-request via forUser()
36
- const user = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' })
35
+ // Bind the context once per request.
36
+ const user = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' });
37
37
 
38
- const flag = user.getBoolFlag('new-checkout-flow', false)
39
- if (flag.isEnabled()) {
40
- renderNewCheckout()
41
- }
38
+ const flag = user.getBoolFlag('new-checkout-flow', false);
39
+ const checkoutTemplate = flag.isEnabled() ? 'checkout-v2' : 'checkout-v1';
42
40
 
43
- user.track('checkout_started', { value: 99.0 })
41
+ user.track('checkout_started', { revenue: 99.0 });
44
42
  ```
45
43
 
46
- Instantiate `AvsbServer` once at app boot. The server holds the datafile in memory and refreshes it on a polling interval (or via SSE when `streaming: true`).
44
+ Construct `AvsbServer` once at boot. It holds the datafile in memory, refreshes over SSE when the environment publishes, and falls back to polling when streaming is unavailable.
47
45
 
48
46
  ---
49
47
 
50
- ## 3. SDK keys
48
+ ## 3. Constructor options
49
+
50
+ Every type named on this page is exported by the package, so the shapes below (and your own declarations) can import them:
51
+
52
+ ```ts
53
+ import type {
54
+ DecideOption,
55
+ DecisionLog,
56
+ EvalContext,
57
+ EvaluationSource,
58
+ Flag,
59
+ FlagDatafile,
60
+ GetAllFlagsOptions,
61
+ Logger,
62
+ ServerGetFlagOptions,
63
+ StickyBucketService,
64
+ } from '@avsbhq/node';
65
+ ```
51
66
 
52
- Get the SDK key for the environment you want to target from your A vs B project's Environments page. The key format is `sdk_<environment>_<id>` (e.g. `sdk_production_clp1a2b3c4d5e6`).
67
+ ```ts
68
+ interface AvsbServerOptions {
69
+ /** `sdk_<environment>_<id>`, e.g. sdk_production_ttqm0eaj4vth1krcb2xn. */
70
+ sdkKey: string;
71
+ /** Polling interval in ms. Default 60_000. Clamped to a 5_000 minimum, jittered. */
72
+ pollingInterval?: number;
73
+ /** Datafile CDN host. Default 'https://cdn.avsb.cloud'. */
74
+ cdnHost?: string;
75
+ /** Max ms onReady() waits for the first datafile. Default 10_000. */
76
+ initTimeout?: number;
77
+ /** A datafile you already have, so onReady() resolves with no network call. */
78
+ bootstrap?: FlagDatafile;
79
+ /** SSE datafile updates. Default TRUE on the server. */
80
+ streaming?: boolean;
81
+ /** Stream ticket endpoint. Overrides the datafile's own `streamEndpoint`. */
82
+ streamingEndpoint?: string;
83
+ /** Event tracker tuning. */
84
+ events?: EventOptions;
85
+ /** Sticky bucketing store. */
86
+ stickyBucketService?: StickyBucketService;
87
+ /** Your logger. Default: console at warn level outside production. */
88
+ logger?: Logger;
89
+ /** Shorthand for the default logger's level. 'silent' restores no output. */
90
+ logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'silent';
91
+ }
92
+
93
+ interface EventOptions {
94
+ /** Ms between automatic flushes. Default 2000. */
95
+ flushInterval?: number;
96
+ /** Max events per request. Default 10. */
97
+ batchSize?: number;
98
+ /** Queue cap; oldest dropped with a warning. Default 10_000. */
99
+ maxQueueSize?: number;
100
+ /** Retry attempts per failed flush. Default 3. */
101
+ retryAttempts?: number;
102
+ }
103
+ ```
104
+
105
+ ### SDK keys
106
+
107
+ Copy the key for the environment you want from app.avsb.cloud, then Settings, then Environments. The format is `sdk_<environment>_<id>`.
53
108
 
54
109
  ```bash
55
- AVSB_SDK_KEY=sdk_production_...
110
+ AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xn
56
111
  ```
57
112
 
58
- When used server-side, the SDK key only ever needs to leave the server to hit the datafile CDN; it should never end up in client-rendered HTML, browser bundles, or browser-accessible config. Store it in environment variables only.
113
+ The shape is checked at construction. A key that does not look like one is logged with what looked wrong and where the real key lives; the SDK carries on with the value you passed, because the datafile request is the real arbiter.
59
114
 
60
- Server SDK keys cannot be rotated without a process restart; the new key must be set in the environment before the server boots (or trigger a rolling restart in your orchestrator).
115
+ Keep the key in environment variables. The SDK reads it once, at construction, so moving an environment to a new key means setting the new value and restarting the process (or constructing a new client and closing the old one).
61
116
 
62
117
  ---
63
118
 
64
119
  ## 4. Identity
65
120
 
66
- The server SDK is stateless per request; it does not hold a persistent context. Pass the user context on each call using `forUser()` or as a `context` option on individual calls.
121
+ The server holds no context of its own: you supply it per request. The examples below read a request object shaped like this, whatever your framework calls it:
67
122
 
68
- ### `forUser(context)` / `createUserContext(context)`
123
+ ```ts
124
+ interface AppRequest {
125
+ user: { id: string; plan: string; country: string };
126
+ org: { id: string; tier: string };
127
+ }
128
+
129
+ declare const req: AppRequest;
130
+ ```
69
131
 
70
- Creates a `UserBoundClient` scoped to the given context. All flag and track calls on the returned client use that context automatically. Carries its own per-request decision log.
132
+ ### `forUser(context)`, the primary pattern
71
133
 
72
134
  ```ts
73
135
  const user = server.forUser({
@@ -75,184 +137,269 @@ const user = server.forUser({
75
137
  key: req.user.id,
76
138
  plan: req.user.plan,
77
139
  country: req.user.country,
78
- })
140
+ });
79
141
 
80
- const flag = user.getBoolFlag('checkout-v2', false)
81
- user.track('page_viewed', { properties: { page: '/checkout' } })
142
+ const flag = user.getBoolFlag('checkout-v2', false);
143
+ user.track('page_viewed');
82
144
  ```
83
145
 
84
- `createUserContext` is an ergonomic alias for `forUser`.
146
+ `createUserContext(context)` is an alias for the same thing. The returned `UserBoundClient` carries its own decision log and its own forced decisions.
85
147
 
86
148
  ### Per-call context
87
149
 
88
- Pass `context` in the options object when not using `forUser()`:
150
+ Context is the THIRD POSITIONAL argument on every evaluation call, matching `@avsbhq/edge`:
89
151
 
90
152
  ```ts
91
153
  const flag = server.getBoolFlag('checkout-v2', false, {
92
- context: { kind: 'user', key: 'u_123', plan: 'pro' },
93
- })
154
+ kind: 'user',
155
+ key: 'u_123',
156
+ plan: 'pro',
157
+ });
158
+ ```
159
+
160
+ Calling without a context returns your default with `source: 'not_ready'` and warns once, naming `forUser`. There is nobody to evaluate for.
161
+
162
+ ### Multi-context
163
+
164
+ ```ts
165
+ const user = server.forUser({
166
+ kind: 'multi',
167
+ user: { kind: 'user', key: req.user.id, plan: req.user.plan },
168
+ organization: { kind: 'organization', key: req.org.id, tier: req.org.tier },
169
+ });
170
+ // A rule can target organization.tier while bucketing on user.key.
94
171
  ```
95
172
 
96
173
  ### Commerce targeting attributes
97
174
 
98
- Audiences built with the commerce condition types (cart value, products viewed, purchase history) evaluate from attributes you supply on the context. The attribute names are fixed:
175
+ Audiences built with the commerce condition types (cart value, products viewed, purchase history) read fixed attribute names off the context:
99
176
 
100
177
  ```ts
101
178
  const flag = server.getBoolFlag('big-cart-banner', false, {
102
- context: {
103
- kind: 'user',
104
- key: 'u_123',
105
- cartValue: 120.5, // decimal MAJOR units of the project currency
106
- viewedSkus: ['SKU-1', 'SKU-2'], // for "products viewed" matching on SKU
107
- viewedCategories: ['trainers'], // for "products viewed" matching on category
108
- purchaseHistory: {
109
- // PurchaseHistoryAttribute, exported for your own declarations
110
- hasPurchased: true,
111
- lastPurchaseAt: 1765500000000, // epoch milliseconds (optional)
112
- lifetimeSpend: 540.0, // decimal MAJOR units (optional)
113
- orderCount: 7, // optional
114
- },
179
+ kind: 'user',
180
+ key: 'u_123',
181
+ cartValue: 120.5, // decimal MAJOR units of the project currency
182
+ viewedSkus: ['SKU-1', 'SKU-2'], // "products viewed" matching on SKU
183
+ viewedCategories: ['trainers'], // "products viewed" matching on category
184
+ purchaseHistory: {
185
+ // PurchaseHistoryAttribute, exported for you
186
+ hasPurchased: true,
187
+ lastPurchaseAt: 1765500000000, // epoch milliseconds (optional)
188
+ lifetimeSpend: 540.0, // decimal MAJOR units (optional)
189
+ orderCount: 7, // optional
115
190
  },
116
- })
191
+ });
117
192
  ```
118
193
 
119
- Server-side semantics to be aware of:
120
-
121
- - **All money is decimal major units.** Condition values are stored in minor units and converted using the project currency exponent from the datafile (default 2); you never pass minor units.
122
- - **Missing or mistyped attributes evaluate the condition to `false`.** A completely absent `purchaseHistory` makes ALL purchase-history operators false, including `has_not_purchased`. On the server, absence means "not provided", not "never purchased" (this deliberately diverges from the browser snippet, where a profile miss is authoritative).
123
- - **Dataset-backed "products viewed" conditions evaluate `false` on the server.** Dataset view windows live in the browser; server callers use literal SKU/category lists. The session/30-day window distinction is also browser-only; supply whatever view list you consider current.
194
+ - **All money is decimal major units.** Condition values are stored in minor units and converted with the project currency exponent from the datafile (default 2); you never pass minor units.
195
+ - **Missing or mistyped attributes evaluate to `false`.** A completely absent `purchaseHistory` makes every purchase-history operator false, including `has_not_purchased`. On the server, absence means "not provided", not "never purchased". This deliberately differs from the browser snippet, where a profile miss is authoritative.
196
+ - **Dataset-backed "products viewed" conditions evaluate `false` on the server.** Dataset view windows live in the browser; server callers pass literal SKU or category lists.
124
197
 
125
- ### `alias(previousContext, newContext)`
198
+ ### `alias(previousContext, newContext): void`
126
199
 
127
- Send an alias event linking two identities in the analytics pipeline. Typically called server-side when a user signs up or links an anonymous session to an account:
200
+ Marks the moment a signup or login claimed an anonymous session. Synchronous.
128
201
 
129
202
  ```ts
130
- await server.alias(
131
- { kind: 'user', key: anonymousId },
132
- { kind: 'user', key: user.id }
133
- )
203
+ const anonymousId = 'anon_7f3c9d2b'; // the key you evaluated with before login
204
+ const signedInId = 'u_123'; // the key you evaluate with from now on
205
+
206
+ server.alias({ kind: 'user', key: anonymousId }, { kind: 'user', key: signedInId });
134
207
  ```
135
208
 
209
+ What it does: queues one `avsb_alias` event for the new identity.
210
+
211
+ What it does NOT do, precisely: it does not rewrite sticky assignments, move past exposures, or store the previous key (the conversion wire carries no properties column, so a retroactive session join is not available yet). To keep one visitor's bucketing across a login, evaluate with the SAME context key on both sides.
212
+
136
213
  ### Sharing identity with the web snippet
137
214
 
138
- To measure one metric across both the A vs B web snippet and this SDK (the setup behind cross-project metrics), the context `key` must equal the browser snippet's visitor id: the `_avsb_visitor` cookie UUID returned by `avsb.getVisitorId()`. Results join on raw id equality, so a different key means the two surfaces never line up. Forward the snippet id from the browser and use it as the key here, or link an internal account id to it with `alias()`. Full guide: [Shared Visitor Identity](https://avsb.cloud/docs/developer-reference/shared-visitor-identity).
215
+ To measure one metric across the A vs B web snippet and this SDK, the context `key` must equal the snippet's visitor id: the `_avsb_visitor` cookie UUID that `avsb.getVisitorId()` returns. Results join on raw id equality, so a different key means the two surfaces never line up. Forward the snippet id from the browser and use it as the key here. Full guide: [Shared Visitor Identity](https://avsb.cloud/docs/developer-reference/shared-visitor-identity).
139
216
 
140
217
  ---
141
218
 
142
- ## 5. Multi-context
219
+ ## 5. Reading flags
143
220
 
144
- Pass a multi-context to evaluate flags against multiple context kinds simultaneously:
221
+ ### Signatures
145
222
 
146
223
  ```ts
147
- const user = server.forUser({
148
- kind: 'multi',
149
- user: { kind: 'user', key: req.user.id, plan: req.user.plan },
150
- organization: { kind: 'organization', key: req.org.id, tier: req.org.tier },
151
- })
224
+ getFlag<T>(flagKey: string, defaultValue: T, context?: EvalContext, options?: ServerGetFlagOptions): Flag<T>
225
+ getBoolFlag(flagKey: string, defaultValue: boolean, context?: EvalContext, options?: ServerGetFlagOptions): Flag<boolean>
226
+ getStringFlag(flagKey: string, defaultValue: string, context?: EvalContext, options?: ServerGetFlagOptions): Flag<string>
227
+ getNumberFlag(flagKey: string, defaultValue: number, context?: EvalContext, options?: ServerGetFlagOptions): Flag<number>
228
+ getJsonFlag<T>(flagKey: string, defaultValue: T, context?: EvalContext, options?: ServerGetFlagOptions): Flag<T>
229
+ getAllFlags(context?: EvalContext, options?: GetAllFlagsOptions): Record<string, Flag>
230
+ ```
231
+
232
+ The two options bags those signatures name:
233
+
234
+ ```ts
235
+ interface ServerGetFlagOptions {
236
+ decideOptions?: DecideOption[];
237
+ decisionLog?: DecisionLog;
238
+ }
152
239
 
153
- const flag = user.getBoolFlag('enterprise-dashboard', false)
154
- // The rule may target organization.tier and bucket on user.key simultaneously
240
+ interface GetAllFlagsOptions {
241
+ /** Record an exposure per flag. Default FALSE. */
242
+ fireExposures?: boolean;
243
+ decideOptions?: DecideOption[];
244
+ decisionLog?: DecisionLog;
245
+ }
155
246
  ```
156
247
 
157
- ---
248
+ On a `UserBoundClient` the context is already bound, so the same calls read `getBoolFlag(key, default, options?)`.
158
249
 
159
- ## 6. Reading flags
250
+ One deliberate difference from `@avsbhq/browser`: there, `getAllFlags` takes an options bag (`getAllFlags({ context })`) because the browser client is context-bound and the bag disambiguates its optional arguments; here the context is the first positional argument, matching every other server evaluation call.
160
251
 
161
- ### Typed variants (recommended)
252
+ Typed getters check the value against the flag's declared type in the datafile. On a mismatch they log a warning naming both types and return YOUR default; they never throw and never coerce.
253
+
254
+ ### The `Flag<T>` object
162
255
 
163
256
  ```ts
164
- const darkMode = user.getBoolFlag('dark-mode', false)
165
- const theme = user.getStringFlag('theme', 'light')
166
- const maxItems = user.getNumberFlag('max-results', 25)
167
- const config = user.getJsonFlag<{ timeout: number }>('api-config', { timeout: 5000 })
257
+ interface Flag<T = unknown> {
258
+ readonly value: T;
259
+ readonly variationKey: string | null;
260
+ readonly source: EvaluationSource;
261
+ readonly ruleId: string | null;
262
+ readonly ruleType: 'targeted_delivery' | 'ab_test' | 'holdout' | 'bandit' | null;
263
+ readonly reasons: string[];
264
+ readonly evaluatedAt: number;
265
+ readonly durationMicros: number;
266
+ isEnabled(): boolean;
267
+ exists(): boolean;
268
+ }
168
269
  ```
169
270
 
170
- Each typed variant performs a runtime type check against the datafile's declared flag type. A mismatch logs a warning and returns the default.
271
+ `isEnabled()` is true only when a REAL decision produced a truthy value: sources `rule`, `holdout`, `bandit`, `runtimeOverride`, `datafileOverride`, `sticky`. It is always false for `default`, `not_found`, `disabled`, and `not_ready`.
171
272
 
172
- ### Generic `getFlag<T>`
273
+ `exists()` answers "is this key in the datafile": false for `not_found`, and false for `not_ready` because with no datafile the question is unanswerable.
173
274
 
174
- ```ts
175
- const flag = server.getFlag<boolean>('checkout-v2', false, {
176
- context: { kind: 'user', key: 'u_123' },
177
- })
178
- ```
275
+ | `source` | Meaning |
276
+ | ------------------ | ------------------------------------------------------------------------------ |
277
+ | `datafileOverride` | A per-user override configured in the dashboard matched. |
278
+ | `runtimeOverride` | `setOverrideForUser`, `setGlobalOverride`, or a forced decision matched. |
279
+ | `sticky` | A stored assignment was reused. |
280
+ | `rule` | A targeting or A/B rule matched. |
281
+ | `holdout` | The context is held out; the holdout variation was served. |
282
+ | `bandit` | A bandit rule picked the variation. |
283
+ | `default` | The flag exists, nothing matched, its default variation was served. |
284
+ | `disabled` | The flag exists but is off in this environment. |
285
+ | `not_found` | The datafile loaded and has no such key. Check the key name. |
286
+ | `not_ready` | No datafile yet, or no context passed. Await `onReady()`, or pass `bootstrap`. |
179
287
 
180
- ### The `Flag<T>` object
288
+ ### `DecideOption`
181
289
 
182
290
  ```ts
183
- flag.value // T, the variation value
184
- flag.isEnabled() // true if source === 'rule' && value is truthy
185
- flag.variationKey // 'on' | 'off' | 'control' | null (null = default served)
186
- flag.exists() // false when source === 'not_found'
187
- flag.source // 'rule' | 'sticky' | 'holdout' | 'datafileOverride' | 'default' | ...
188
- flag.ruleId // which rule matched
189
- flag.reasons // string[], evaluation path
291
+ import { DecideOption } from '@avsbhq/node';
292
+
293
+ user.getBoolFlag('checkout-v2', false, {
294
+ decideOptions: [DecideOption.DISABLE_EXPOSURE],
295
+ });
190
296
  ```
191
297
 
192
- ### `getAllFlags(context, options?)`
298
+ Two members, both accepted:
299
+
300
+ - `DISABLE_EXPOSURE` evaluates without recording an exposure. Use it for admin panels, previews, and bulk reads.
301
+ - `INCLUDE_REASONS` asks for reason strings. This SDK collects reasons unconditionally, so passing it changes nothing here; it is real in the per-language SDKs that gate reason collection behind it.
302
+
303
+ Anything else warns once with the accepted list. `EXCLUDE_VARIABLES`, `IGNORE_STICKY_BUCKET`, and `ENABLED_FLAGS_ONLY` were deleted: no evaluator in any language implemented them.
304
+
305
+ ### `getAllFlags`
193
306
 
194
307
  ```ts
195
- const all = server.getAllFlags({ kind: 'user', key: 'u_123' })
196
- // Exposures are suppressed by default. Pass { fireExposures: true } to enable.
308
+ const all = server.getAllFlags({ kind: 'user', key: 'u_123' });
309
+ // Exposures are suppressed by default. Pass { fireExposures: true } to record them.
197
310
  ```
198
311
 
199
- ### Forced decisions (QA / testing)
312
+ Called without a context, or before the datafile loads, it returns `{}` AND warns, because an empty map is otherwise indistinguishable from a project with no flags.
200
313
 
201
- ```ts
202
- // Override for a specific user context key
203
- server.setOverrideForUser('checkout-v2', 'u_qa', 'on')
204
- server.setOverrideForUser('checkout-v2', 'u_qa', 'on', 'user') // explicit kind
314
+ ### Overrides, for QA and testing
205
315
 
206
- // Global override (all contexts)
207
- server.setGlobalOverride('checkout-v2', 'on')
316
+ ```ts
317
+ server.setOverrideForUser('checkout-v2', 'u_qa', 'on');
318
+ server.setOverrideForUser('checkout-v2', 'acme', 'on', 'organization'); // explicit kind
319
+ server.setGlobalOverride('checkout-v2', 'on');
208
320
 
209
- // Clear
210
- server.clearOverrideForUser('checkout-v2', 'u_qa')
211
- server.clearGlobalOverride('checkout-v2')
212
- server.clearAllOverrides()
321
+ server.clearOverrideForUser('checkout-v2', 'u_qa');
322
+ server.clearGlobalOverride('checkout-v2');
323
+ server.clearAllOverrides();
213
324
  ```
214
325
 
215
- Per-request forced decisions (bound to a `UserBoundClient`):
326
+ Per-request forced decisions, which never touch the shared state above:
216
327
 
217
328
  ```ts
218
- const user = server.forUser(ctx)
219
- user.setForcedDecision('checkout-v2', { variationKey: 'on' })
220
- user.clearForcedDecision('checkout-v2')
329
+ const user = server.forUser({ kind: 'user', key: 'u_qa' });
330
+ user.setForcedDecision('checkout-v2', { variationKey: 'on' });
331
+ user.clearForcedDecision('checkout-v2');
221
332
  ```
222
333
 
223
334
  ---
224
335
 
225
- ## 7. Tracking events
336
+ ## 6. Tracking
226
337
 
227
338
  ```ts
228
- user.track('purchase_completed', {
229
- value: 199.0,
230
- properties: { plan: 'annual', sku: 'PRO_ANNUAL' },
231
- })
339
+ // Bound client: context implied.
340
+ user.track('purchase_completed', { revenue: 199.0 }); // money
341
+ user.track('items_added', { value: 3 }); // a quantity
342
+ user.track('signup_completed'); // a count
343
+
344
+ // Server directly: context in the payload.
345
+ server.track('purchase_completed', {
346
+ revenue: 199.0,
347
+ context: { kind: 'user', key: 'u_123' },
348
+ });
232
349
  ```
233
350
 
234
- When called directly on `server` (not via `forUser`), include `context`:
351
+ `TrackPayload` carries two numbers, and they are two columns end to end:
352
+
353
+ | Field | Meaning | Storage column |
354
+ | --------- | ----------------------------------------------------------------------- | ----------------------- |
355
+ | `revenue` | Money in decimal MAJOR units of the project currency (49.99, not 4999). | `metric_events.revenue` |
356
+ | `value` | Numeric metric value for average-value metrics: items, seats, seconds. | `metric_events.value` |
357
+
358
+ One conversion can carry money, a quantity, or both. Earlier releases mapped `value` into the revenue column and could never write the value column, so a quantity was recorded as money and every average-value metric read empty.
359
+
360
+ `track` is synchronous: it queues the event and the tracker batches it. Events tracked before the datafile arrives are QUEUED (bounded by `events.maxQueueSize`) and sent once it does.
361
+
362
+ `payload.properties` is accepted by the shared type but NOT stored for conversions: the ingestion contract for a metric event keeps the event name, the two numbers, the timestamp, and the visitor id. The SDK warns once and drops them rather than pretending. Exposure events do carry properties.
363
+
364
+ ### Purchases
235
365
 
236
366
  ```ts
237
- server.track('purchase_completed', {
238
- value: 199.0,
239
- context: { kind: 'user', key: 'u_123' },
240
- properties: { plan: 'annual' },
241
- })
367
+ await server.trackPurchase('u_123', {
368
+ orderId: 'order_1001',
369
+ total: 199.0,
370
+ currency: 'USD',
371
+ items: [{ sku: 'PRO_ANNUAL', price: 199.0, quantity: 1 }],
372
+ });
373
+
374
+ // Or from a bound client, attributed to its context key:
375
+ await user.trackPurchase({ orderId: 'order_1001', total: 199.0 });
242
376
  ```
243
377
 
244
- Events are synchronous calls that queue the event for batch flushing. There is no `await` on `track`.
378
+ Purchases are queued before ready exactly like `track`. They used to be dropped with a warning while `track` queued.
379
+
380
+ ### Manual exposure
381
+
382
+ ```ts
383
+ const flag = user.getBoolFlag('checkout-v2', false, {
384
+ decideOptions: [DecideOption.DISABLE_EXPOSURE],
385
+ });
386
+
387
+ // Later, at the point the visitor actually sees the variation:
388
+ user.manualExposure('checkout-v2');
389
+ ```
245
390
 
246
391
  ---
247
392
 
248
- ## 8. Error handling
393
+ ## 7. Errors, logging, and events
394
+
395
+ ### Logger
249
396
 
250
- ### `logger` option
397
+ The default is a console logger at WARN level when `NODE_ENV` is neither `production` nor `test`, and a noop otherwise. A wrong SDK key is loud in development and silent in production logs.
251
398
 
252
399
  ```ts
253
- import { AvsbServer, createLogger } from '@avsbhq/node'
254
- import pino from 'pino'
255
- import { createPinoTransport } from '@avsbhq/utils/log/pino'
400
+ import { AvsbServer, createLogger } from '@avsbhq/node';
401
+ import pino from 'pino';
402
+ import { createPinoTransport } from '@avsbhq/utils/log/pino';
256
403
 
257
404
  const server = new AvsbServer({
258
405
  sdkKey: process.env.AVSB_SDK_KEY!,
@@ -260,242 +407,399 @@ const server = new AvsbServer({
260
407
  level: 'info',
261
408
  transports: [createPinoTransport({ logger: pino(), level: 'info' })],
262
409
  }),
263
- })
410
+ });
411
+
412
+ // Or just turn the volume down:
413
+ new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY!, logLevel: 'silent' });
264
414
  ```
265
415
 
266
- ### `onReady()` error handling
416
+ ### `onReady()`
267
417
 
268
418
  ```ts
269
- const result = await server.onReady()
270
- // result.success: boolean
271
- // result.source: 'network' | 'bootstrap' | 'timeout' | 'error'
272
- // result.degraded: true means init failed but defaults are served; polling continues
273
- if (!result.success && !result.degraded) {
274
- throw result.error
419
+ interface InitResult {
420
+ success: boolean;
421
+ source: 'network' | 'bootstrap' | 'timeout' | 'error';
422
+ error?: Error;
423
+ /** Serving stale-but-real values after a failed refresh. Not set by this SDK today. */
424
+ degraded?: boolean;
425
+ }
426
+
427
+ const result = await server.onReady();
428
+ if (!result.success) {
429
+ // result.source is 'timeout' or 'error'; result.error says which.
275
430
  }
276
431
  ```
277
432
 
278
- `onReady()` never rejects; it always resolves. Check `result.success` to decide whether to proceed.
433
+ `onReady()` never rejects and returns the same promise on repeated calls. `isReady()` answers "are flags readable right now" synchronously.
279
434
 
280
- ### Event bus
435
+ ### Events
281
436
 
282
437
  ```ts
283
- const unsubscribe = server.on('error', (err) => {
284
- myLogger.error('SDK error', { error: err.message })
285
- })
286
-
438
+ server.on('ready', () => {});
287
439
  server.on('configUpdate', ({ publishedAt, reason }) => {
288
- myLogger.info('datafile refreshed', { publishedAt, reason })
289
- })
440
+ // reason: 'poll' | 'stream' | 'manual'
441
+ });
442
+ server.on('evaluation', ({ flagKey, flag, contextKeys }) => {});
443
+ server.on('exposure', (event) => {
444
+ // Same eventId the ingestion pipeline receives, so your own analytics rows
445
+ // reconcile with A vs B results.
446
+ });
447
+ server.on('error', (error) => {});
448
+ server.on('stickyOrphan', ({ flagKey, userId, assignment }) => {});
449
+
450
+ // on() returns the unsubscribe:
451
+ const unsubscribe = server.on('error', (error) => console.error(error));
452
+ unsubscribe();
290
453
  ```
291
454
 
455
+ The payloads are the shared `ClientEventMap` shapes, so a listener written against `@avsbhq/browser` reads the same fields here. The server does not emit `flagChange`: it holds one datafile for the process and does not diff individual flags. Subscribing to it through `client.utils` warns and hands back a no-op disposer.
456
+
292
457
  ---
293
458
 
294
- ## 9. SSR / hydration
459
+ ## 8. Heartbeat and SDK health
460
+
461
+ The SDK reports liveness to the endpoint the datafile publishes
462
+ (`heartbeatEndpoint`), so the dashboard can show whether an environment is
463
+ actually being served.
464
+
465
+ - **What it posts**: `{ sdkKey, sdkType, sdkVersion }`, nothing else. No flag
466
+ values, no context, no counts.
467
+ - **When**: on every successful datafile REQUEST (init, poll, stream-triggered
468
+ refresh, and `refreshDatafile()`), throttled to at most one every 2 minutes.
469
+ A `304 Not Modified` counts: the SDK sends a conditional request once it has
470
+ an ETag, so an unchanged datafile answers 304 on every poll, and that is
471
+ exactly the steady state of a healthy process.
472
+ - **Fire and forget**: a failed heartbeat is dropped, never retried, and never
473
+ affects evaluation.
474
+ - **Where it shows up**: Settings, then Environments, in the dashboard. Each
475
+ environment shows Connected, Stale, or Not connected, plus the `sdkType` and
476
+ `sdkVersion` that last reported, which is how you see "production is running
477
+ @avsbhq/node 1.6.2". An environment goes Stale after five minutes without a
478
+ heartbeat.
479
+ - **Turning it off**: publish a datafile with no `heartbeatEndpoint`. The SDK
480
+ never guesses a host, so an absent field simply means no heartbeats.
481
+
482
+ ---
295
483
 
296
- Not applicable to server-side Node.js usage. For Next.js App Router SSR, see `@avsbhq/next` which provides `getDatafile`, `evaluateFlagServer`, and `AvsbHydrator` for the server-to-client bootstrap handoff.
484
+ ## 9. Streaming
297
485
 
298
- For server-side rendering outside of Next.js, fetch the datafile via `bootstrap` to avoid an extra round-trip:
486
+ Streaming is ON by default. The endpoint comes from the datafile's `streamEndpoint` field, which the publisher controls, so a self-hosted or proxied deployment needs no client-side configuration.
299
487
 
300
488
  ```ts
301
- const server = new AvsbServer({
302
- sdkKey: process.env.AVSB_SDK_KEY!,
303
- bootstrap: cachedDatafile, // pre-fetched FlagDatafile object
304
- })
305
- // onReady() resolves immediately with source: 'bootstrap'
489
+ const sdkKey = process.env.AVSB_SDK_KEY!;
490
+ const streamingEndpoint = 'https://stream.avsb.cloud/v1/ticket';
491
+
492
+ new AvsbServer({ sdkKey }); // streaming on, endpoint from the datafile
493
+ new AvsbServer({ sdkKey, streaming: false }); // polling only
494
+ new AvsbServer({ sdkKey, streamingEndpoint }); // explicit endpoint wins
306
495
  ```
307
496
 
308
- ---
497
+ If no endpoint is available, or the stream drops, the SDK keeps polling and logs the reason at debug level. Nothing is guessed from another host: pointing a reconnect loop at DNS that does not resolve is the failure mode this design exists to prevent.
309
498
 
310
- ## 10. Graceful shutdown
499
+ ---
311
500
 
312
- ### SIGTERM handler
501
+ ## 10. Bootstrap and serverless cold starts
313
502
 
314
503
  ```ts
315
- process.on('SIGTERM', async () => {
316
- await server.close() // abort in-flight fetches, drain events
317
- process.exit(0)
318
- })
504
+ import type { BootstrapBlob } from '@avsbhq/utils';
505
+
506
+ // You already have the datafile (KV, a file, a cache), and a blob from a
507
+ // server that called serializeBootstrap.
508
+ declare const cachedDatafile: FlagDatafile;
509
+ declare const blob: BootstrapBlob;
510
+
511
+ const server = new AvsbServer({ sdkKey, bootstrap: cachedDatafile });
512
+ // onReady() resolves immediately with source: 'bootstrap'.
513
+
514
+ // Or hand it over after construction:
515
+ server.applyDatafileBootstrap(cachedDatafile);
516
+
517
+ // Same thing through the bootstrap blob helpers:
518
+ server.utils.hydrateBootstrap(blob); // blob must carry `datafile`
319
519
  ```
320
520
 
321
- `close()` calls `server.flush()` internally, then aborts any open streaming connections and stops polling.
521
+ `hydrateBootstrap` THROWS on a blob that carries only a pre-evaluated snapshot: a snapshot belongs to one context, and a server evaluates a different context per request, so adopting one process-wide would serve one user's values to another. Serialize with `{ includeDatafile: true }`, or pass `bootstrap: blob.datafile`.
522
+
523
+ For Next.js SSR see `@avsbhq/next`.
524
+
525
+ ---
322
526
 
323
- ### Manual flush
527
+ ## 11. Shutdown
324
528
 
325
529
  ```ts
326
- await server.flush() // drain queued events without teardown
530
+ process.on('SIGTERM', async () => {
531
+ await server.close();
532
+ process.exit(0);
533
+ });
534
+
535
+ await server.flush(); // drain queued events without tearing down
327
536
  ```
328
537
 
538
+ `close()` aborts in-flight fetches, disconnects the stream, stops polling, and drains the event queue.
539
+
329
540
  ---
330
541
 
331
- ## 11. Testing
542
+ ## 12. Verifying inbound webhooks
543
+
544
+ AvsB signs every outbound webhook delivery with HMAC-SHA256 over `deliveryId + "." + timestamp + "." + rawBody` (the Standard Webhooks scheme). `verifyWebhookSignature` checks it against the **raw** body bytes, enforces a replay window, and compares digests in constant time.
332
545
 
333
546
  ```ts
334
- import { createMockClient, TestData } from '@avsbhq/test'
547
+ // docs-example: not typechecked here, because express is not a dependency of the A vs B repository, so its Request and Response types cannot be resolved for the route handler
548
+
549
+ import express from 'express';
550
+ import { verifyWebhookSignature } from '@avsbhq/node';
335
551
 
336
- const td = TestData.flag('checkout-v2')
337
- .booleanFlag()
338
- .variationForUser('u_test', true)
339
- .fallthroughVariation(false)
552
+ const app = express();
340
553
 
341
- const mock = createMockClient({ flags: [td.build()] })
342
- // mock has the same interface as AvsbServer; inject it in tests
554
+ async function handleEvent(deliveryId: string, event: unknown): Promise<void> {
555
+ // your own idempotent processing, keyed on deliveryId
556
+ }
557
+
558
+ // express.raw(), NOT express.json(): parsing and re-serialising the body changes
559
+ // its bytes, and the signature is over the bytes we sent.
560
+ app.post('/avsb-webhook', express.raw({ type: 'application/json' }), (req, res) => {
561
+ const result = verifyWebhookSignature(req.body, req.headers, process.env.AVSB_WEBHOOK_SECRET!);
562
+ if (!result.ok) {
563
+ // 'missing_headers' | 'malformed_signature' | 'malformed_timestamp'
564
+ // | 'timestamp_out_of_tolerance' | 'signature_mismatch'
565
+ return res.status(401).send(result.reason);
566
+ }
567
+
568
+ const event = JSON.parse(req.body.toString('utf8'));
569
+ void handleEvent(result.deliveryId, event); // retries reuse the id: dedupe on it
570
+ res.sendStatus(200);
571
+ });
343
572
  ```
344
573
 
345
- For assertion helpers:
574
+ | Option | Default | Notes |
575
+ | ------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------- |
576
+ | `toleranceSeconds` | `300` | Reject deliveries signed more than this long ago (or ahead, so clock skew is caught). `0` disables the age check. |
577
+ | `now` | `Date.now` | Injectable clock, for tests. |
346
578
 
347
- ```ts
348
- import { recordEvaluations } from '@avsbhq/test'
579
+ Works with Node's `req.headers`, a plain object with any casing, or anything exposing `get(name)` (a `Headers` instance, a Next.js `Request`).
580
+
581
+ ---
349
582
 
350
- const recorder = recordEvaluations(mock)
351
- callCodeUnderTest(mock)
352
- expect(recorder).toHaveEvaluated('checkout-v2')
353
- .withContext({ kind: 'user', key: 'u_test' })
354
- .withValue(true)
583
+ ## 13. Testing
584
+
585
+ ```ts
586
+ import { createMockServer, flagsFromTestData, TestData } from '@avsbhq/test';
587
+
588
+ const server = createMockServer(
589
+ flagsFromTestData([
590
+ TestData.flag('checkout-v2')
591
+ .booleanFlag()
592
+ .variationForUser('u_paying', true)
593
+ .fallthroughVariation(false)
594
+ .build(),
595
+ ]),
596
+ );
597
+
598
+ const user = server.forUser({ kind: 'user', key: 'u_paying' });
599
+ expect(user.getBoolFlag('checkout-v2', false).value).toBe(true);
600
+ expect(server._evaluations()).toHaveLength(1);
355
601
  ```
356
602
 
603
+ `createMockServer` mirrors this SDK's surface, `forUser` included. `createMockClient` mirrors the browser client. Both read the same fixture through `flagsFromTestData`.
604
+
357
605
  ---
358
606
 
359
- ## 12. Framework integrations
607
+ ## 14. Framework integrations
360
608
 
361
609
  ### Express
362
610
 
363
611
  ```ts
364
- import express from 'express'
365
- import { AvsbServer, expressMiddleware } from '@avsbhq/node'
612
+ import express from 'express';
613
+ import { AvsbServer, expressMiddleware } from '@avsbhq/node';
614
+ import { getRequestClient } from '@avsbhq/utils';
615
+
616
+ const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! });
617
+ await avsb.onReady();
618
+
619
+ const app = express();
620
+ app.use(
621
+ expressMiddleware(avsb, {
622
+ // The middleware reads the request structurally, so narrow it to whatever
623
+ // your auth layer put there.
624
+ contextFrom: (req) => {
625
+ const authed = req.user as { id?: string; plan?: string } | undefined;
626
+ return { kind: 'user', key: authed?.id ?? 'anon', plan: authed?.plan };
627
+ },
628
+ }),
629
+ );
366
630
 
367
- const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })
368
- await avsb.onReady()
631
+ // Deep inside a service, with no `req` in scope:
632
+ function pricingService() {
633
+ return getRequestClient()?.getBoolFlag('new-pricing', false).value ?? false;
634
+ }
635
+ ```
369
636
 
370
- const app = express()
371
- app.use(expressMiddleware(avsb, {
372
- contextFrom: (req) => ({
373
- kind: 'user',
374
- key: req.user?.id ?? 'anon',
375
- plan: req.user?.plan,
376
- }),
377
- }))
637
+ Every route then reads flags off `req.avsb`:
638
+
639
+ ```ts
640
+ // docs-example: not typechecked here, because express is not a dependency of the A vs B repository, so a route handler's Request and Response types cannot be resolved
378
641
 
379
642
  app.get('/checkout', (req, res) => {
380
- const flag = req.avsb.getBoolFlag('new-checkout-flow', false)
381
- res.render(flag.isEnabled() ? 'checkout-v2' : 'checkout-v1')
382
- })
643
+ const flag = req.avsb.getBoolFlag('new-checkout-flow', false);
644
+ res.render(flag.isEnabled() ? 'checkout-v2' : 'checkout-v1');
645
+ });
383
646
  ```
384
647
 
385
- `expressMiddleware` decorates each request with `req.avsb`: a `UserBoundClient` scoped to the context returned by `contextFrom`. It also opens an `AsyncLocalStorage` scope so `getRequestClient()` from `@avsbhq/utils` works anywhere in the call chain.
648
+ Return `undefined` from `contextFrom` to skip AvsB for that request.
649
+
650
+ `expressMiddleware` and `fastifyPlugin` ARE the implementations from `@avsbhq/utils/middleware/*`, re-exported here so one dependency is enough. There is exactly one signature for each; this package used to ship a second, incompatible `expressMiddleware(client, { userIdFrom })`.
651
+
652
+ Type `req.avsb` in your own project (four lines, once):
653
+
654
+ ```ts
655
+ // src/types/express.d.ts
656
+ import type { UserBoundClient } from '@avsbhq/node';
657
+
658
+ declare global {
659
+ namespace Express {
660
+ interface Request {
661
+ avsb: UserBoundClient;
662
+ }
663
+ }
664
+ }
665
+ ```
386
666
 
387
667
  ### Fastify
388
668
 
389
669
  ```ts
390
- import Fastify from 'fastify'
391
- import { AvsbServer, fastifyPlugin } from '@avsbhq/node'
670
+ // docs-example: not typechecked here, because fastify is not a dependency of the A vs B repository, so neither the instance nor its route handler types can be resolved
671
+
672
+ import Fastify from 'fastify';
673
+ import { AvsbServer, fastifyPlugin } from '@avsbhq/node';
392
674
 
393
- const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })
394
- await avsb.onReady()
675
+ const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! });
676
+ await avsb.onReady();
395
677
 
396
- const app = Fastify()
678
+ const app = Fastify();
397
679
  await app.register(fastifyPlugin, {
398
680
  server: avsb,
399
- contextFrom: (req) => ({
400
- kind: 'user',
401
- key: req.headers['x-user-id'] as string,
402
- }),
403
- })
681
+ contextFrom: (req) => ({ kind: 'user', key: req.headers['x-user-id'] as string }),
682
+ });
404
683
 
405
684
  app.get('/features', (req, reply) => {
406
- const flag = req.avsb.getBoolFlag('new-feature', false)
407
- reply.send({ enabled: flag.isEnabled() })
408
- })
685
+ reply.send({ enabled: req.avsb.getBoolFlag('new-feature', false).isEnabled() });
686
+ });
409
687
  ```
410
688
 
689
+ ### Other frameworks
690
+
691
+ Hono, Koa, Next (app and pages), Lambda, and Cloudflare Workers adapters live in `@avsbhq/utils/middleware/*`: `honoMiddleware`, `koaMiddleware`, `nextAppHandler`, `nextPagesApi`, `lambdaHandler`, `workersHandler`. All open the same AsyncLocalStorage scope, so `getRequestClient()` works under every one of them.
692
+
411
693
  ### Sticky bucketing
412
694
 
413
- Keep users in the same variation across rule changes. In-memory is suitable for single-process deployments:
695
+ Keep users in the same variation across rule changes. Sticky lookup runs BEFORE the engine, so a stored assignment survives a rollout edit.
414
696
 
415
697
  ```ts
416
- import { InMemoryStickyBucketService } from '@avsbhq/node'
417
-
418
- const server = new AvsbServer({
419
- sdkKey: process.env.AVSB_SDK_KEY!,
420
- stickyBucketService: new InMemoryStickyBucketService(),
421
- })
698
+ import { createClient } from 'redis';
699
+ import { InMemoryStickyBucketService, RedisStickyBucketService } from '@avsbhq/node';
700
+
701
+ // Single process:
702
+ new AvsbServer({ sdkKey, stickyBucketService: new InMemoryStickyBucketService() });
703
+
704
+ // Multi-process, multi-region:
705
+ const redis = createClient({ url: process.env.REDIS_URL });
706
+ await redis.connect();
707
+ new AvsbServer({
708
+ sdkKey,
709
+ stickyBucketService: new RedisStickyBucketService({ redis, prefix: 'avsb:sticky:' }),
710
+ });
422
711
  ```
423
712
 
424
- Redis for multi-process and multi-region deployments:
713
+ The Redis service keeps evaluation synchronous: a lookup is a cache hit or null, with a background fill, so the first lookup per process misses by design. DynamoDB, Postgres, and Durable Object services live in `@avsbhq/utils`.
425
714
 
426
- ```ts
427
- import { createClient } from 'redis'
428
- import { RedisStickyBucketService } from '@avsbhq/node'
715
+ ### Decision logs
429
716
 
430
- const redis = createClient({ url: process.env.REDIS_URL })
431
- await redis.connect()
717
+ ```ts
718
+ const user = server.forUser({ kind: 'user', key: 'u_123' });
719
+ user.getBoolFlag('checkout-v2', false);
432
720
 
433
- const server = new AvsbServer({
434
- sdkKey: process.env.AVSB_SDK_KEY!,
435
- stickyBucketService: new RedisStickyBucketService({
436
- redis,
437
- prefix: 'avsb:sticky:',
438
- }),
439
- })
721
+ const entries = user.getDecisionLog().entries;
722
+ // [{ flagKey, value, variationKey, source, ruleId, ruleType, reasons,
723
+ // evaluatedAt, durationMicros, contextSummary }]
440
724
  ```
441
725
 
442
- DynamoDB and PostgreSQL adapters are available in `@avsbhq/utils`.
443
-
444
- ### Real-time streaming
445
-
446
- Enable SSE streaming to receive datafile updates the instant a flag changes, with no polling delay:
726
+ Redaction is real: any attribute named in `_meta.privateAttributes` has its VALUE replaced with `[REDACTED]` wherever it appears in a logged value or a reason string. Values shorter than two characters are matched exactly rather than as substrings, because a one-character value cannot be told apart from ordinary text.
447
727
 
448
728
  ```ts
449
- const server = new AvsbServer({
450
- sdkKey: process.env.AVSB_SDK_KEY!,
451
- streaming: true,
452
- })
729
+ server.forUser({
730
+ kind: 'user',
731
+ key: 'u_123',
732
+ email: 'ada@example.com',
733
+ _meta: { privateAttributes: ['email'] },
734
+ });
453
735
  ```
454
736
 
455
- ### Decision logs
737
+ ---
456
738
 
457
- Capture full evaluation reasons per request for debugging or audit:
739
+ ## 15. Migration
458
740
 
459
- ```ts
460
- import { createDecisionLog } from '@avsbhq/node'
741
+ ### From LaunchDarkly Node
461
742
 
462
- const log = createDecisionLog()
463
- const user = server.forUser({ kind: 'user', key: 'u_123' })
464
- const flag = user.getBoolFlag('checkout-v2', false)
465
- // The bound client's decision log captures all evaluations automatically:
466
- const entries = user.getDecisionLog().entries
467
- // [{ flagKey, value, variationKey, source, ruleId, ruleType, reasons, evaluatedAt }]
468
- ```
743
+ | LaunchDarkly Node | `@avsbhq/node` |
744
+ | ------------------------------------------------- | ------------------------------------------------------------ |
745
+ | `init(sdkKey, options)` | `new AvsbServer({ sdkKey })` |
746
+ | `client.waitForInitialization()` | `await server.onReady()` |
747
+ | `client.variation('key', context, default)` | `server.forUser(ctx).getBoolFlag('key', default).value` |
748
+ | `client.variationDetail('key', context, default)` | `server.forUser(ctx).getFlag('key', default)` |
749
+ | `client.track('event', context, { metricValue })` | `server.forUser(ctx).track('event', { value })` |
750
+ | `client.identify(context)` | not applicable (the server is stateless); use `forUser(ctx)` |
751
+ | `client.allFlagsState(context)` | `server.getAllFlags(ctx)` |
752
+ | `client.close()` | `await server.close()` |
753
+
754
+ Key differences: `getFlag` returns `Flag<T>`, not a raw value (`.value` for the primitive); `track` uses `value`, not `metricValue`; all evaluation is per-request via `forUser()`; multi-context is native.
755
+
756
+ ### From Statsig Node
757
+
758
+ | Statsig Node | `@avsbhq/node` |
759
+ | ---------------------------------------------------------- | ------------------------------------------------------------ |
760
+ | `Statsig.initialize(serverSecret)` | `new AvsbServer({ sdkKey })` |
761
+ | `Statsig.checkGate(user, 'gate')` | `server.forUser(ctx).getBoolFlag('gate', false).isEnabled()` |
762
+ | `Statsig.getExperiment(user, 'exp').get('param', default)` | `server.forUser(ctx).getFlag('exp', default).value` |
763
+ | `Statsig.logEvent(user, 'event', value, metadata)` | `server.forUser(ctx).track('event', { value })` |
764
+ | `Statsig.flush()` | `await server.flush()` |
765
+
766
+ ### From an earlier @avsbhq/node
767
+
768
+ | Was | Now |
769
+ | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
770
+ | `getFlag(key, default, { context })` | `getFlag(key, default, context)` |
771
+ | `getAllFlags(context, opts)` | same call; `context` is now optional and warns when omitted |
772
+ | `expressMiddleware(client, { userIdFrom })` | `expressMiddleware(server, { contextFrom })` |
773
+ | Pre-ready reads reported `not_found` | they report `not_ready` |
774
+ | `evaluation` event `{ flagKey, value, userId, source }` | `{ flagKey, flag, contextKeys }` |
775
+ | `DecideOption` members beyond `DISABLE_EXPOSURE` and `INCLUDE_REASONS` | deleted; they were never implemented |
776
+ | `streaming` defaulted to false | defaults to true, with silent fallback to polling |
777
+ | `track(key, { value: 199.0 })` for money | `track(key, { revenue: 199.0 })`; `value` now means an average-value quantity |
778
+ | `server.alias(...)` documented but absent | implemented, synchronous, and documented for what it really does |
469
779
 
470
780
  ---
471
781
 
472
- ## 13. Migration
782
+ ## 16. Set this up with your AI assistant
473
783
 
474
- ### From LaunchDarkly Node
784
+ Paste this into Claude Code, Cursor, or any coding assistant:
475
785
 
476
- | LaunchDarkly Node | `@avsbhq/node` |
477
- |---|---|
478
- | `init(sdkKey, options)` | `new AvsbServer({ sdkKey })` |
479
- | `client.waitForInitialization()` | `await server.onReady()` |
480
- | `client.variation('key', context, default)` | `server.forUser(ctx).getBoolFlag('key', default).value` |
481
- | `client.variationDetail('key', context, default)` | `server.forUser(ctx).getFlag('key', default)` |
482
- | `client.track('event', context, { metricValue })` | `server.forUser(ctx).track('event', { value })` |
483
- | `client.identify(context)` | not applicable (server is stateless); use `forUser(ctx)` |
484
- | `client.allFlagsState(context)` | `server.getAllFlags(ctx)` |
485
- | `client.close()` | `await server.close()` |
486
-
487
- Key differences:
488
- - `getFlag` returns `Flag<T>` not a raw value. Access `.value` for the primitive.
489
- - `track` uses `value` (not `metricValue`) for the numeric metric.
490
- - The server is context-free at construction; all evaluation is per-request via `forUser()`.
491
- - Multi-context is native, with no `ldUser` adapter needed.
786
+ ```text
787
+ Set up A vs B feature flags in this Node.js project.
492
788
 
493
- ### From Statsig Node
789
+ 1. Run: npx @avsbhq/cli init
790
+ Use my saved CLI login: do not ask me for a token and do not put one in any file.
791
+ It writes the SDK key into .env as AVSB_SDK_KEY and writes an example file.
792
+ 2. Install @avsbhq/node with this project's package manager.
793
+ 3. Construct AvsbServer once at boot with that key, await server.onReady(), and
794
+ bind a context per request with server.forUser({ kind: 'user', key: userId }).
795
+ 4. Every getter returns a Flag object, so read .value or call .isEnabled(), and
796
+ always pass a fallback. Before the datafile arrives, reads return your fallback
797
+ and flag.source is 'not_ready'.
798
+ 5. Then run: npx @avsbhq/cli codegen
799
+
800
+ Done looks like: the process starts, the flag reads without throwing, and avsb
801
+ init prints the line confirming it saw the first check-in.
802
+ ```
494
803
 
495
- | Statsig Node | `@avsbhq/node` |
496
- |---|---|
497
- | `Statsig.initialize(serverSecret)` | `new AvsbServer({ sdkKey })` |
498
- | `Statsig.checkGate(user, 'gate')` | `server.forUser(ctx).getBoolFlag('gate', false).isEnabled()` |
499
- | `Statsig.getExperiment(user, 'exp').get('param', default)` | `server.forUser(ctx).getFlag('exp', default).value` |
500
- | `Statsig.logEvent(user, 'event', value, metadata)` | `server.forUser(ctx).track('event', { value, properties: metadata })` |
501
- | `Statsig.flush()` | `await server.flush()` |
804
+ `avsb init` ends by waiting for your app's first check-in and printing what it
805
+ saw, so the terminal tells you it works rather than the dashboard.