@avsbhq/node 0.3.0 → 1.2.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 +345 -145
- package/dist/index.cjs +720 -343
- package/dist/index.d.cts +140 -121
- package/dist/index.d.ts +140 -121
- package/dist/index.js +727 -340
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -2,267 +2,467 @@
|
|
|
2
2
|
|
|
3
3
|
Node.js server-side SDK for the [A vs B](https://app.avsb.cloud) platform.
|
|
4
4
|
|
|
5
|
-
Evaluate feature flags, bucket users consistently, and track conversion events on the server. Includes
|
|
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.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Install
|
|
8
10
|
|
|
9
11
|
```bash
|
|
10
12
|
npm install @avsbhq/node
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
You'll need a server SDK key from your A vs B project's environments page.
|
|
15
|
+
Requires Node.js 18 or later.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
2. Open your project, go to **Environments**
|
|
19
|
-
3. Copy the **server** SDK key for the environment you want to use
|
|
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.
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
---
|
|
22
20
|
|
|
23
|
-
##
|
|
21
|
+
## 2. Quickstart
|
|
24
22
|
|
|
25
23
|
```ts
|
|
26
|
-
import {
|
|
24
|
+
import { AvsbServer } from '@avsbhq/node'
|
|
27
25
|
|
|
28
|
-
const server = new
|
|
26
|
+
const server = new AvsbServer({
|
|
29
27
|
sdkKey: process.env.AVSB_SDK_KEY!,
|
|
30
28
|
})
|
|
31
29
|
|
|
32
|
-
await server.onReady()
|
|
30
|
+
const result = await server.onReady()
|
|
31
|
+
if (!result.success) {
|
|
32
|
+
process.exit(1) // or degrade gracefully: result.degraded === true means defaults are safe
|
|
33
|
+
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
attributes: { plan: 'pro', country: 'US' },
|
|
37
|
-
})
|
|
35
|
+
// Evaluate per-request via forUser()
|
|
36
|
+
const user = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' })
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
38
|
+
const flag = user.getBoolFlag('new-checkout-flow', false)
|
|
39
|
+
if (flag.isEnabled()) {
|
|
40
|
+
renderNewCheckout()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
user.track('checkout_started', { value: 99.0 })
|
|
43
44
|
```
|
|
44
45
|
|
|
45
|
-
|
|
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`).
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
---
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
## 3. SDK keys
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|---|---|---|---|
|
|
53
|
-
| `sdkKey` | `string` | — | **Required.** Server SDK key for the environment. |
|
|
54
|
-
| `pollingInterval` | `number` | `60_000` | How often (ms) to refresh the datafile. Min `5_000`. |
|
|
55
|
-
| `cdnHost` | `string` | `'https://cdn.avsb.cloud'` | CDN host for datafile fetches. |
|
|
56
|
-
| `initTimeout` | `number` | `10_000` | Max ms to wait for initial datafile fetch. |
|
|
57
|
-
| `bootstrap` | `FlagDatafile` | — | Pre-fetched datafile to skip the initial network request. |
|
|
58
|
-
| `streaming` | `boolean` | `false` | Enable SSE streaming for real-time datafile updates. |
|
|
59
|
-
| `streamingEndpoint` | `string` | derived | Custom SSE endpoint URL. |
|
|
60
|
-
| `events` | `EventOptions` | defaults | Event tracking config (`flushInterval`, `batchSize`, `maxQueueSize`, `retryAttempts`). |
|
|
61
|
-
| `stickyBucketService` | `StickyBucketService` | none | Pluggable sticky bucketing backend. |
|
|
62
|
-
| `logger` | `Logger` | `noopLogger` | Custom logger (`debug` / `info` / `warn` / `error`). |
|
|
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`).
|
|
63
53
|
|
|
64
|
-
|
|
54
|
+
```bash
|
|
55
|
+
AVSB_SDK_KEY=sdk_production_...
|
|
56
|
+
```
|
|
57
|
+
|
|
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.
|
|
59
|
+
|
|
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).
|
|
61
|
+
|
|
62
|
+
---
|
|
65
63
|
|
|
66
|
-
|
|
64
|
+
## 4. Identity
|
|
65
|
+
|
|
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.
|
|
67
|
+
|
|
68
|
+
### `forUser(context)` / `createUserContext(context)`
|
|
69
|
+
|
|
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.
|
|
67
71
|
|
|
68
72
|
```ts
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
const user = server.forUser({
|
|
74
|
+
kind: 'user',
|
|
75
|
+
key: req.user.id,
|
|
76
|
+
plan: req.user.plan,
|
|
77
|
+
country: req.user.country,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const flag = user.getBoolFlag('checkout-v2', false)
|
|
81
|
+
user.track('page_viewed', { properties: { page: '/checkout' } })
|
|
73
82
|
```
|
|
74
83
|
|
|
75
|
-
`
|
|
84
|
+
`createUserContext` is an ergonomic alias for `forUser`.
|
|
76
85
|
|
|
77
|
-
###
|
|
86
|
+
### Per-call context
|
|
78
87
|
|
|
79
|
-
|
|
88
|
+
Pass `context` in the options object when not using `forUser()`:
|
|
80
89
|
|
|
81
90
|
```ts
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
attributes: { plan: 'pro' },
|
|
91
|
+
const flag = server.getBoolFlag('checkout-v2', false, {
|
|
92
|
+
context: { kind: 'user', key: 'u_123', plan: 'pro' },
|
|
85
93
|
})
|
|
86
94
|
```
|
|
87
95
|
|
|
88
|
-
`
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
### `alias(previousContext, newContext)`
|
|
97
|
+
|
|
98
|
+
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:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
await server.alias(
|
|
102
|
+
{ kind: 'user', key: anonymousId },
|
|
103
|
+
{ kind: 'user', key: user.id }
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
96
108
|
|
|
97
|
-
|
|
109
|
+
## 5. Multi-context
|
|
98
110
|
|
|
99
|
-
|
|
111
|
+
Pass a multi-context to evaluate flags against multiple context kinds simultaneously:
|
|
100
112
|
|
|
101
113
|
```ts
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
const user = server.forUser({
|
|
115
|
+
kind: 'multi',
|
|
116
|
+
user: { kind: 'user', key: req.user.id, plan: req.user.plan },
|
|
117
|
+
organization: { kind: 'organization', key: req.org.id, tier: req.org.tier },
|
|
106
118
|
})
|
|
119
|
+
|
|
120
|
+
const flag = user.getBoolFlag('enterprise-dashboard', false)
|
|
121
|
+
// The rule may target organization.tier and bucket on user.key simultaneously
|
|
107
122
|
```
|
|
108
123
|
|
|
109
|
-
|
|
124
|
+
---
|
|
110
125
|
|
|
111
|
-
|
|
126
|
+
## 6. Reading flags
|
|
127
|
+
|
|
128
|
+
### Typed variants (recommended)
|
|
112
129
|
|
|
113
130
|
```ts
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
const darkMode = user.getBoolFlag('dark-mode', false)
|
|
132
|
+
const theme = user.getStringFlag('theme', 'light')
|
|
133
|
+
const maxItems = user.getNumberFlag('max-results', 25)
|
|
134
|
+
const config = user.getJsonFlag<{ timeout: number }>('api-config', { timeout: 5000 })
|
|
116
135
|
```
|
|
117
136
|
|
|
118
|
-
|
|
137
|
+
Each typed variant performs a runtime type check against the datafile's declared flag type. A mismatch logs a warning and returns the default.
|
|
119
138
|
|
|
120
|
-
###
|
|
139
|
+
### Generic `getFlag<T>`
|
|
121
140
|
|
|
122
141
|
```ts
|
|
123
|
-
|
|
124
|
-
|
|
142
|
+
const flag = server.getFlag<boolean>('checkout-v2', false, {
|
|
143
|
+
context: { kind: 'user', key: 'u_123' },
|
|
144
|
+
})
|
|
145
|
+
```
|
|
125
146
|
|
|
126
|
-
|
|
127
|
-
await server.onReady()
|
|
147
|
+
### The `Flag<T>` object
|
|
128
148
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
149
|
+
```ts
|
|
150
|
+
flag.value // T — the variation value
|
|
151
|
+
flag.isEnabled() // true if source === 'rule' && value is truthy
|
|
152
|
+
flag.variationKey // 'on' | 'off' | 'control' | null (null = default served)
|
|
153
|
+
flag.exists() // false when source === 'not_found'
|
|
154
|
+
flag.source // 'rule' | 'sticky' | 'holdout' | 'datafileOverride' | 'default' | ...
|
|
155
|
+
flag.ruleId // which rule matched
|
|
156
|
+
flag.reasons // string[] — evaluation path
|
|
157
|
+
```
|
|
134
158
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
})
|
|
159
|
+
### `getAllFlags(context, options?)`
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const all = server.getAllFlags({ kind: 'user', key: 'u_123' })
|
|
163
|
+
// Exposures are suppressed by default. Pass { fireExposures: true } to enable.
|
|
139
164
|
```
|
|
140
165
|
|
|
141
|
-
|
|
166
|
+
### Forced decisions (QA / testing)
|
|
142
167
|
|
|
143
|
-
|
|
168
|
+
```ts
|
|
169
|
+
// Override for a specific user context key
|
|
170
|
+
server.setOverrideForUser('checkout-v2', 'u_qa', 'on')
|
|
171
|
+
server.setOverrideForUser('checkout-v2', 'u_qa', 'on', 'user') // explicit kind
|
|
172
|
+
|
|
173
|
+
// Global override (all contexts)
|
|
174
|
+
server.setGlobalOverride('checkout-v2', 'on')
|
|
175
|
+
|
|
176
|
+
// Clear
|
|
177
|
+
server.clearOverrideForUser('checkout-v2', 'u_qa')
|
|
178
|
+
server.clearGlobalOverride('checkout-v2')
|
|
179
|
+
server.clearAllOverrides()
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Per-request forced decisions (bound to a `UserBoundClient`):
|
|
144
183
|
|
|
145
184
|
```ts
|
|
146
|
-
|
|
147
|
-
|
|
185
|
+
const user = server.forUser(ctx)
|
|
186
|
+
user.setForcedDecision('checkout-v2', { variationKey: 'on' })
|
|
187
|
+
user.clearForcedDecision('checkout-v2')
|
|
188
|
+
```
|
|
148
189
|
|
|
149
|
-
|
|
150
|
-
await server.onReady()
|
|
190
|
+
---
|
|
151
191
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
192
|
+
## 7. Tracking events
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
user.track('purchase_completed', {
|
|
196
|
+
value: 199.0,
|
|
197
|
+
properties: { plan: 'annual', sku: 'PRO_ANNUAL' },
|
|
156
198
|
})
|
|
157
199
|
```
|
|
158
200
|
|
|
159
|
-
|
|
201
|
+
When called directly on `server` (not via `forUser`), include `context`:
|
|
160
202
|
|
|
161
|
-
|
|
203
|
+
```ts
|
|
204
|
+
server.track('purchase_completed', {
|
|
205
|
+
value: 199.0,
|
|
206
|
+
context: { kind: 'user', key: 'u_123' },
|
|
207
|
+
properties: { plan: 'annual' },
|
|
208
|
+
})
|
|
209
|
+
```
|
|
162
210
|
|
|
163
|
-
|
|
211
|
+
Events are synchronous calls that queue the event for batch flushing. There is no `await` on `track`.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 8. Error handling
|
|
216
|
+
|
|
217
|
+
### `logger` option
|
|
164
218
|
|
|
165
219
|
```ts
|
|
166
|
-
import {
|
|
220
|
+
import { AvsbServer, createLogger } from '@avsbhq/node'
|
|
221
|
+
import pino from 'pino'
|
|
222
|
+
import { createPinoTransport } from '@avsbhq/utils/log/pino'
|
|
167
223
|
|
|
168
|
-
const server = new
|
|
224
|
+
const server = new AvsbServer({
|
|
169
225
|
sdkKey: process.env.AVSB_SDK_KEY!,
|
|
170
|
-
|
|
226
|
+
logger: createLogger({
|
|
227
|
+
level: 'info',
|
|
228
|
+
transports: [createPinoTransport({ logger: pino(), level: 'info' })],
|
|
229
|
+
}),
|
|
171
230
|
})
|
|
172
231
|
```
|
|
173
232
|
|
|
174
|
-
###
|
|
233
|
+
### `onReady()` error handling
|
|
175
234
|
|
|
176
235
|
```ts
|
|
177
|
-
|
|
178
|
-
|
|
236
|
+
const result = await server.onReady()
|
|
237
|
+
// result.success: boolean
|
|
238
|
+
// result.source: 'network' | 'bootstrap' | 'timeout' | 'error'
|
|
239
|
+
// result.degraded: true means init failed but defaults are served; polling continues
|
|
240
|
+
if (!result.success && !result.degraded) {
|
|
241
|
+
throw result.error
|
|
242
|
+
}
|
|
243
|
+
```
|
|
179
244
|
|
|
180
|
-
|
|
181
|
-
await redis.connect()
|
|
245
|
+
`onReady()` never rejects — it always resolves. Check `result.success` to decide whether to proceed.
|
|
182
246
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
247
|
+
### Event bus
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
const unsubscribe = server.on('error', (err) => {
|
|
251
|
+
myLogger.error('SDK error', { error: err.message })
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
server.on('configUpdate', ({ publishedAt, reason }) => {
|
|
255
|
+
myLogger.info('datafile refreshed', { publishedAt, reason })
|
|
186
256
|
})
|
|
187
257
|
```
|
|
188
258
|
|
|
189
|
-
|
|
259
|
+
---
|
|
190
260
|
|
|
191
|
-
##
|
|
261
|
+
## 9. SSR / hydration
|
|
192
262
|
|
|
193
|
-
|
|
263
|
+
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.
|
|
264
|
+
|
|
265
|
+
For server-side rendering outside of Next.js, fetch the datafile via `bootstrap` to avoid an extra round-trip:
|
|
194
266
|
|
|
195
267
|
```ts
|
|
196
|
-
const server = new
|
|
268
|
+
const server = new AvsbServer({
|
|
197
269
|
sdkKey: process.env.AVSB_SDK_KEY!,
|
|
198
|
-
|
|
270
|
+
bootstrap: cachedDatafile, // pre-fetched FlagDatafile object
|
|
271
|
+
})
|
|
272
|
+
// onReady() resolves immediately with source: 'bootstrap'
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## 10. Graceful shutdown
|
|
278
|
+
|
|
279
|
+
### SIGTERM handler
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
process.on('SIGTERM', async () => {
|
|
283
|
+
await server.close() // abort in-flight fetches, drain events
|
|
284
|
+
process.exit(0)
|
|
199
285
|
})
|
|
200
286
|
```
|
|
201
287
|
|
|
202
|
-
|
|
288
|
+
`close()` calls `server.flush()` internally, then aborts any open streaming connections and stops polling.
|
|
289
|
+
|
|
290
|
+
### Manual flush
|
|
203
291
|
|
|
204
292
|
```ts
|
|
205
|
-
|
|
293
|
+
await server.flush() // drain queued events without teardown
|
|
294
|
+
```
|
|
206
295
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## 11. Testing
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { createMockClient, TestData } from '@avsbhq/test'
|
|
302
|
+
|
|
303
|
+
const td = TestData.flag('checkout-v2')
|
|
304
|
+
.booleanFlag()
|
|
305
|
+
.variationForUser('u_test', true)
|
|
306
|
+
.fallthroughVariation(false)
|
|
307
|
+
|
|
308
|
+
const mock = createMockClient({ flags: [td.build()] })
|
|
309
|
+
// mock has the same interface as AvsbServer; inject it in tests
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
For assertion helpers:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import { recordEvaluations } from '@avsbhq/test'
|
|
316
|
+
|
|
317
|
+
const recorder = recordEvaluations(mock)
|
|
318
|
+
callCodeUnderTest(mock)
|
|
319
|
+
expect(recorder).toHaveEvaluated('checkout-v2')
|
|
320
|
+
.withContext({ kind: 'user', key: 'u_test' })
|
|
321
|
+
.withValue(true)
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## 12. Framework integrations
|
|
327
|
+
|
|
328
|
+
### Express
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import express from 'express'
|
|
332
|
+
import { AvsbServer, expressMiddleware } from '@avsbhq/node'
|
|
333
|
+
|
|
334
|
+
const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })
|
|
335
|
+
await avsb.onReady()
|
|
336
|
+
|
|
337
|
+
const app = express()
|
|
338
|
+
app.use(expressMiddleware(avsb, {
|
|
339
|
+
contextFrom: (req) => ({
|
|
340
|
+
kind: 'user',
|
|
341
|
+
key: req.user?.id ?? 'anon',
|
|
342
|
+
plan: req.user?.plan,
|
|
343
|
+
}),
|
|
344
|
+
}))
|
|
345
|
+
|
|
346
|
+
app.get('/checkout', (req, res) => {
|
|
347
|
+
const flag = req.avsb.getBoolFlag('new-checkout-flow', false)
|
|
348
|
+
res.render(flag.isEnabled() ? 'checkout-v2' : 'checkout-v1')
|
|
210
349
|
})
|
|
211
350
|
```
|
|
212
351
|
|
|
213
|
-
|
|
352
|
+
`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.
|
|
214
353
|
|
|
215
|
-
|
|
354
|
+
### Fastify
|
|
216
355
|
|
|
217
356
|
```ts
|
|
218
|
-
import
|
|
357
|
+
import Fastify from 'fastify'
|
|
358
|
+
import { AvsbServer, fastifyPlugin } from '@avsbhq/node'
|
|
219
359
|
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
360
|
+
const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })
|
|
361
|
+
await avsb.onReady()
|
|
362
|
+
|
|
363
|
+
const app = Fastify()
|
|
364
|
+
await app.register(fastifyPlugin, {
|
|
365
|
+
server: avsb,
|
|
366
|
+
contextFrom: (req) => ({
|
|
367
|
+
kind: 'user',
|
|
368
|
+
key: req.headers['x-user-id'] as string,
|
|
369
|
+
}),
|
|
224
370
|
})
|
|
225
371
|
|
|
226
|
-
|
|
227
|
-
|
|
372
|
+
app.get('/features', (req, reply) => {
|
|
373
|
+
const flag = req.avsb.getBoolFlag('new-feature', false)
|
|
374
|
+
reply.send({ enabled: flag.isEnabled() })
|
|
375
|
+
})
|
|
228
376
|
```
|
|
229
377
|
|
|
230
|
-
|
|
378
|
+
### Sticky bucketing
|
|
231
379
|
|
|
232
|
-
|
|
380
|
+
Keep users in the same variation across rule changes. In-memory is suitable for single-process deployments:
|
|
233
381
|
|
|
234
382
|
```ts
|
|
235
|
-
|
|
236
|
-
import { AvsBServer } from '@avsbhq/node'
|
|
383
|
+
import { InMemoryStickyBucketService } from '@avsbhq/node'
|
|
237
384
|
|
|
238
|
-
|
|
239
|
-
|
|
385
|
+
const server = new AvsbServer({
|
|
386
|
+
sdkKey: process.env.AVSB_SDK_KEY!,
|
|
387
|
+
stickyBucketService: new InMemoryStickyBucketService(),
|
|
388
|
+
})
|
|
240
389
|
```
|
|
241
390
|
|
|
391
|
+
Redis for multi-process and multi-region deployments:
|
|
392
|
+
|
|
242
393
|
```ts
|
|
243
|
-
|
|
244
|
-
import {
|
|
394
|
+
import { createClient } from 'redis'
|
|
395
|
+
import { RedisStickyBucketService } from '@avsbhq/node'
|
|
245
396
|
|
|
246
|
-
const
|
|
397
|
+
const redis = createClient({ url: process.env.REDIS_URL })
|
|
398
|
+
await redis.connect()
|
|
399
|
+
|
|
400
|
+
const server = new AvsbServer({
|
|
401
|
+
sdkKey: process.env.AVSB_SDK_KEY!,
|
|
402
|
+
stickyBucketService: new RedisStickyBucketService({
|
|
403
|
+
redis,
|
|
404
|
+
prefix: 'avsb:sticky:',
|
|
405
|
+
}),
|
|
406
|
+
})
|
|
247
407
|
```
|
|
248
408
|
|
|
249
|
-
|
|
409
|
+
DynamoDB and PostgreSQL adapters are available in `@avsbhq/utils`.
|
|
410
|
+
|
|
411
|
+
### Real-time streaming
|
|
412
|
+
|
|
413
|
+
Enable SSE streaming to receive datafile updates the instant a flag changes — no polling delay:
|
|
250
414
|
|
|
251
415
|
```ts
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
416
|
+
const server = new AvsbServer({
|
|
417
|
+
sdkKey: process.env.AVSB_SDK_KEY!,
|
|
418
|
+
streaming: true,
|
|
255
419
|
})
|
|
256
420
|
```
|
|
257
421
|
|
|
258
|
-
|
|
422
|
+
### Decision logs
|
|
423
|
+
|
|
424
|
+
Capture full evaluation reasons per request for debugging or audit:
|
|
259
425
|
|
|
260
|
-
|
|
261
|
-
|
|
426
|
+
```ts
|
|
427
|
+
import { createDecisionLog } from '@avsbhq/node'
|
|
262
428
|
|
|
263
|
-
|
|
429
|
+
const log = createDecisionLog()
|
|
430
|
+
const user = server.forUser({ kind: 'user', key: 'u_123' })
|
|
431
|
+
const flag = user.getBoolFlag('checkout-v2', false)
|
|
432
|
+
// The bound client's decision log captures all evaluations automatically:
|
|
433
|
+
const entries = user.getDecisionLog().entries
|
|
434
|
+
// [{ flagKey, value, variationKey, source, ruleId, ruleType, reasons, evaluatedAt }]
|
|
435
|
+
```
|
|
264
436
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## 13. Migration
|
|
440
|
+
|
|
441
|
+
### From LaunchDarkly Node
|
|
442
|
+
|
|
443
|
+
| LaunchDarkly Node | `@avsbhq/node` |
|
|
444
|
+
|---|---|
|
|
445
|
+
| `init(sdkKey, options)` | `new AvsbServer({ sdkKey })` |
|
|
446
|
+
| `client.waitForInitialization()` | `await server.onReady()` |
|
|
447
|
+
| `client.variation('key', context, default)` | `server.forUser(ctx).getBoolFlag('key', default).value` |
|
|
448
|
+
| `client.variationDetail('key', context, default)` | `server.forUser(ctx).getFlag('key', default)` |
|
|
449
|
+
| `client.track('event', context, { metricValue })` | `server.forUser(ctx).track('event', { value })` |
|
|
450
|
+
| `client.identify(context)` | not applicable — server is stateless; use `forUser(ctx)` |
|
|
451
|
+
| `client.allFlagsState(context)` | `server.getAllFlags(ctx)` |
|
|
452
|
+
| `client.close()` | `await server.close()` |
|
|
453
|
+
|
|
454
|
+
Key differences:
|
|
455
|
+
- `getFlag` returns `Flag<T>` not a raw value. Access `.value` for the primitive.
|
|
456
|
+
- `track` uses `value` (not `metricValue`) for the numeric metric.
|
|
457
|
+
- The server is context-free at construction; all evaluation is per-request via `forUser()`.
|
|
458
|
+
- Multi-context is native — no `ldUser` adapter needed.
|
|
459
|
+
|
|
460
|
+
### From Statsig Node
|
|
461
|
+
|
|
462
|
+
| Statsig Node | `@avsbhq/node` |
|
|
463
|
+
|---|---|
|
|
464
|
+
| `Statsig.initialize(serverSecret)` | `new AvsbServer({ sdkKey })` |
|
|
465
|
+
| `Statsig.checkGate(user, 'gate')` | `server.forUser(ctx).getBoolFlag('gate', false).isEnabled()` |
|
|
466
|
+
| `Statsig.getExperiment(user, 'exp').get('param', default)` | `server.forUser(ctx).getFlag('exp', default).value` |
|
|
467
|
+
| `Statsig.logEvent(user, 'event', value, metadata)` | `server.forUser(ctx).track('event', { value, properties: metadata })` |
|
|
468
|
+
| `Statsig.flush()` | `await server.flush()` |
|