@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/dist/index.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import { evaluateFlag } from "@avsbhq/
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
};
|
|
1
|
+
// src/AvsbServer.ts
|
|
2
|
+
import { evaluateFlag } from "@avsbhq/core/engine";
|
|
3
|
+
import {
|
|
4
|
+
toLegacyEvalContext,
|
|
5
|
+
createFlag,
|
|
6
|
+
notFoundFlag as notFoundFlag2,
|
|
7
|
+
mapLegacySource,
|
|
8
|
+
checkExpectedType,
|
|
9
|
+
findDatafileFlag,
|
|
10
|
+
contextSummary,
|
|
11
|
+
privateAttributesFrom,
|
|
12
|
+
bucketingKey,
|
|
13
|
+
noopLogger
|
|
14
|
+
} from "@avsbhq/core";
|
|
15
15
|
|
|
16
16
|
// src/decisionLog.ts
|
|
17
17
|
function createDecisionLog() {
|
|
@@ -20,6 +20,42 @@ function createDecisionLog() {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// src/serverTracker.ts
|
|
23
|
+
import { randomUUID } from "crypto";
|
|
24
|
+
|
|
25
|
+
// src/reconnectBackoff.ts
|
|
26
|
+
var BASE_DELAY_MS = 1e3;
|
|
27
|
+
var MAX_DELAY_MS = 6e4;
|
|
28
|
+
var MIN_DELAY_MS = 500;
|
|
29
|
+
var JITTER = 0.2;
|
|
30
|
+
function nextBackoff(prev, retryAfterMs) {
|
|
31
|
+
const attempt = (prev?.attempt ?? 0) + 1;
|
|
32
|
+
if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
|
|
33
|
+
return { attempt, delayMs: retryAfterMs };
|
|
34
|
+
}
|
|
35
|
+
const base = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (attempt - 1));
|
|
36
|
+
const jitter = base * JITTER * (Math.random() * 2 - 1);
|
|
37
|
+
const delayMs = Math.max(MIN_DELAY_MS, Math.round(base + jitter));
|
|
38
|
+
return { attempt, delayMs };
|
|
39
|
+
}
|
|
40
|
+
function resetBackoff() {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function parseRetryAfter(headerValue) {
|
|
44
|
+
if (!headerValue) return null;
|
|
45
|
+
const trimmed = headerValue.trim();
|
|
46
|
+
if (!trimmed) return null;
|
|
47
|
+
const n = Number(trimmed);
|
|
48
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
49
|
+
return Math.round(n * 1e3);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/serverTracker.ts
|
|
53
|
+
function stampExposure(event) {
|
|
54
|
+
return event.eventId ? event : { ...event, eventId: randomUUID() };
|
|
55
|
+
}
|
|
56
|
+
function stampMetric(event) {
|
|
57
|
+
return event.eventId ? event : { ...event, eventId: randomUUID() };
|
|
58
|
+
}
|
|
23
59
|
function createServerTracker(options) {
|
|
24
60
|
const {
|
|
25
61
|
sdkKey,
|
|
@@ -32,6 +68,7 @@ function createServerTracker(options) {
|
|
|
32
68
|
} = options;
|
|
33
69
|
const batchEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/batch");
|
|
34
70
|
const metricEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/metric-batch");
|
|
71
|
+
const ordersEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/orders");
|
|
35
72
|
let exposureQueue = [];
|
|
36
73
|
let metricQueue = [];
|
|
37
74
|
let flushTimer = null;
|
|
@@ -43,7 +80,7 @@ function createServerTracker(options) {
|
|
|
43
80
|
}, flushInterval);
|
|
44
81
|
function queue(event) {
|
|
45
82
|
if (destroyed) return;
|
|
46
|
-
exposureQueue.push(event);
|
|
83
|
+
exposureQueue.push(stampExposure(event));
|
|
47
84
|
if (exposureQueue.length > maxQueueSize) {
|
|
48
85
|
const dropped = exposureQueue.length - maxQueueSize;
|
|
49
86
|
exposureQueue = exposureQueue.slice(-maxQueueSize);
|
|
@@ -57,7 +94,7 @@ function createServerTracker(options) {
|
|
|
57
94
|
}
|
|
58
95
|
function queueMetric(event) {
|
|
59
96
|
if (destroyed) return;
|
|
60
|
-
metricQueue.push(event);
|
|
97
|
+
metricQueue.push(stampMetric(event));
|
|
61
98
|
if (metricQueue.length > maxQueueSize) {
|
|
62
99
|
const dropped = metricQueue.length - maxQueueSize;
|
|
63
100
|
metricQueue = metricQueue.slice(-maxQueueSize);
|
|
@@ -88,6 +125,8 @@ function createServerTracker(options) {
|
|
|
88
125
|
}
|
|
89
126
|
async function sendBatch(endpoint, key, payload) {
|
|
90
127
|
const body = JSON.stringify({ sdkKey, [key]: payload });
|
|
128
|
+
let backoff = resetBackoff();
|
|
129
|
+
let pendingRetryAfterMs = void 0;
|
|
91
130
|
for (let attempt = 0; attempt <= retryAttempts; attempt++) {
|
|
92
131
|
try {
|
|
93
132
|
const response = await fetch(endpoint, {
|
|
@@ -97,14 +136,17 @@ function createServerTracker(options) {
|
|
|
97
136
|
signal: AbortSignal.timeout(1e4)
|
|
98
137
|
});
|
|
99
138
|
if (!response.ok) {
|
|
139
|
+
const parsed = parseRetryAfter(response.headers.get("retry-after"));
|
|
140
|
+
if (parsed !== null) pendingRetryAfterMs = parsed;
|
|
100
141
|
throw new Error(`HTTP ${response.status}`);
|
|
101
142
|
}
|
|
102
143
|
logger.debug(`Flushed ${payload.length} events to ${endpoint}`);
|
|
103
144
|
return;
|
|
104
145
|
} catch (e) {
|
|
105
146
|
if (attempt < retryAttempts) {
|
|
106
|
-
|
|
107
|
-
|
|
147
|
+
backoff = nextBackoff(backoff, pendingRetryAfterMs);
|
|
148
|
+
pendingRetryAfterMs = void 0;
|
|
149
|
+
await new Promise((r) => setTimeout(r, backoff.delayMs));
|
|
108
150
|
} else {
|
|
109
151
|
logger.error(
|
|
110
152
|
`Failed to flush ${payload.length} events after ${retryAttempts} retries`,
|
|
@@ -114,6 +156,47 @@ function createServerTracker(options) {
|
|
|
114
156
|
}
|
|
115
157
|
}
|
|
116
158
|
}
|
|
159
|
+
async function trackPurchase(visitorId, order) {
|
|
160
|
+
if (destroyed) return;
|
|
161
|
+
const payload = {
|
|
162
|
+
...order,
|
|
163
|
+
eventId: randomUUID(),
|
|
164
|
+
visitorId,
|
|
165
|
+
timestamp: Date.now(),
|
|
166
|
+
pageUrl: ""
|
|
167
|
+
};
|
|
168
|
+
const body = JSON.stringify({ sdkKey, orders: [payload] });
|
|
169
|
+
let backoff = resetBackoff();
|
|
170
|
+
let pendingRetryAfterMs = void 0;
|
|
171
|
+
for (let attempt = 0; attempt <= retryAttempts; attempt++) {
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetch(ordersEndpoint, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "Content-Type": "application/json" },
|
|
176
|
+
body,
|
|
177
|
+
signal: AbortSignal.timeout(1e4)
|
|
178
|
+
});
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
const parsed = parseRetryAfter(response.headers.get("retry-after"));
|
|
181
|
+
if (parsed !== null) pendingRetryAfterMs = parsed;
|
|
182
|
+
throw new Error(`HTTP ${response.status}`);
|
|
183
|
+
}
|
|
184
|
+
logger.debug(`Purchase order ${order.orderId} sent for visitor ${visitorId}`);
|
|
185
|
+
return;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (attempt < retryAttempts) {
|
|
188
|
+
backoff = nextBackoff(backoff, pendingRetryAfterMs);
|
|
189
|
+
pendingRetryAfterMs = void 0;
|
|
190
|
+
await new Promise((r) => setTimeout(r, backoff.delayMs));
|
|
191
|
+
} else {
|
|
192
|
+
logger.error(
|
|
193
|
+
`Failed to send purchase order ${order.orderId} after ${retryAttempts} retries`,
|
|
194
|
+
{ endpoint: ordersEndpoint, error: String(e) }
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
117
200
|
async function destroy() {
|
|
118
201
|
destroyed = true;
|
|
119
202
|
if (flushTimer) {
|
|
@@ -122,20 +205,142 @@ function createServerTracker(options) {
|
|
|
122
205
|
}
|
|
123
206
|
await flush();
|
|
124
207
|
}
|
|
125
|
-
return { queue, queueMetric, flush, destroy };
|
|
208
|
+
return { queue, queueMetric, trackPurchase, flush, destroy };
|
|
126
209
|
}
|
|
127
210
|
|
|
211
|
+
// src/streaming.ts
|
|
212
|
+
import {
|
|
213
|
+
createStreamingClient,
|
|
214
|
+
createBrowserStreamingClient
|
|
215
|
+
} from "@avsbhq/utils/streaming";
|
|
216
|
+
|
|
128
217
|
// src/version.ts
|
|
129
218
|
var SDK_VERSION = "0.3.0";
|
|
130
219
|
|
|
131
|
-
// src/
|
|
220
|
+
// src/UserBoundClient.ts
|
|
221
|
+
function createUserBoundClient(server, context) {
|
|
222
|
+
const decisionLog = createDecisionLog();
|
|
223
|
+
const forcedDecisions = /* @__PURE__ */ new Map();
|
|
224
|
+
function buildOptions(options) {
|
|
225
|
+
return {
|
|
226
|
+
context,
|
|
227
|
+
decideOptions: options?.decideOptions,
|
|
228
|
+
decisionLog
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
getFlag(flagKey, defaultValue, options) {
|
|
233
|
+
const forced = forcedDecisions.get(flagKey);
|
|
234
|
+
if (forced) {
|
|
235
|
+
return server.getFlag(flagKey, defaultValue, {
|
|
236
|
+
...buildOptions(options),
|
|
237
|
+
forcedVariationKey: forced.variationKey
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return server.getFlag(flagKey, defaultValue, buildOptions(options));
|
|
241
|
+
},
|
|
242
|
+
getBoolFlag(flagKey, defaultValue, options) {
|
|
243
|
+
const forced = forcedDecisions.get(flagKey);
|
|
244
|
+
if (forced) {
|
|
245
|
+
return server.getBoolFlag(flagKey, defaultValue, {
|
|
246
|
+
...buildOptions(options),
|
|
247
|
+
forcedVariationKey: forced.variationKey
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return server.getBoolFlag(flagKey, defaultValue, buildOptions(options));
|
|
251
|
+
},
|
|
252
|
+
getStringFlag(flagKey, defaultValue, options) {
|
|
253
|
+
const forced = forcedDecisions.get(flagKey);
|
|
254
|
+
if (forced) {
|
|
255
|
+
return server.getStringFlag(flagKey, defaultValue, {
|
|
256
|
+
...buildOptions(options),
|
|
257
|
+
forcedVariationKey: forced.variationKey
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return server.getStringFlag(flagKey, defaultValue, buildOptions(options));
|
|
261
|
+
},
|
|
262
|
+
getNumberFlag(flagKey, defaultValue, options) {
|
|
263
|
+
const forced = forcedDecisions.get(flagKey);
|
|
264
|
+
if (forced) {
|
|
265
|
+
return server.getNumberFlag(flagKey, defaultValue, {
|
|
266
|
+
...buildOptions(options),
|
|
267
|
+
forcedVariationKey: forced.variationKey
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return server.getNumberFlag(flagKey, defaultValue, buildOptions(options));
|
|
271
|
+
},
|
|
272
|
+
getJsonFlag(flagKey, defaultValue, options) {
|
|
273
|
+
const forced = forcedDecisions.get(flagKey);
|
|
274
|
+
if (forced) {
|
|
275
|
+
return server.getJsonFlag(flagKey, defaultValue, {
|
|
276
|
+
...buildOptions(options),
|
|
277
|
+
forcedVariationKey: forced.variationKey
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return server.getJsonFlag(flagKey, defaultValue, buildOptions(options));
|
|
281
|
+
},
|
|
282
|
+
getAllFlags(options) {
|
|
283
|
+
return server.getAllFlags(context, options);
|
|
284
|
+
},
|
|
285
|
+
track(eventKey, payload) {
|
|
286
|
+
server.track(eventKey, { ...payload, context });
|
|
287
|
+
},
|
|
288
|
+
setForcedDecision(flagKey, opts) {
|
|
289
|
+
forcedDecisions.set(flagKey, opts);
|
|
290
|
+
},
|
|
291
|
+
clearForcedDecision(flagKey) {
|
|
292
|
+
forcedDecisions.delete(flagKey);
|
|
293
|
+
},
|
|
294
|
+
getDecisionLog() {
|
|
295
|
+
return decisionLog;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/utils.ts
|
|
301
|
+
import { createUtils } from "@avsbhq/utils";
|
|
302
|
+
import { notFoundFlag } from "@avsbhq/core";
|
|
303
|
+
function buildServerUtils(server) {
|
|
304
|
+
const adapter = {
|
|
305
|
+
getFlag(flagKey, defaultValue) {
|
|
306
|
+
return notFoundFlag(defaultValue, "no bound context");
|
|
307
|
+
},
|
|
308
|
+
getAllFlags(opts) {
|
|
309
|
+
const ctx = opts?.context;
|
|
310
|
+
if (!ctx) return {};
|
|
311
|
+
return server.getAllFlags(ctx, {
|
|
312
|
+
fireExposures: opts.fireExposures
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
track(eventKey, payload) {
|
|
316
|
+
if (!payload?.context) return;
|
|
317
|
+
server.track(eventKey, {
|
|
318
|
+
...payload,
|
|
319
|
+
context: payload.context
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
on(event, cb) {
|
|
323
|
+
return server.on(event, cb);
|
|
324
|
+
},
|
|
325
|
+
getDatafile: () => server.getDatafile(),
|
|
326
|
+
getCurrentContext: () => null,
|
|
327
|
+
applyDatafile() {
|
|
328
|
+
},
|
|
329
|
+
applySnapshot() {
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
return createUtils(adapter);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/AvsbServer.ts
|
|
132
336
|
var DEFAULT_EVENTS = {
|
|
133
337
|
flushInterval: 2e3,
|
|
134
338
|
batchSize: 10,
|
|
135
339
|
maxQueueSize: 1e4,
|
|
136
340
|
retryAttempts: 3
|
|
137
341
|
};
|
|
138
|
-
var
|
|
342
|
+
var DEFAULT_STREAMING_ENDPOINT = "https://app.avsb.cloud/api/sdk/stream";
|
|
343
|
+
var AvsbServer = class {
|
|
139
344
|
sdkKey;
|
|
140
345
|
pollingInterval;
|
|
141
346
|
cdnHost;
|
|
@@ -143,14 +348,49 @@ var AvsBServer = class {
|
|
|
143
348
|
logger;
|
|
144
349
|
stickyBucketService;
|
|
145
350
|
eventOptions;
|
|
351
|
+
streamingEnabled;
|
|
352
|
+
streamingEndpoint;
|
|
146
353
|
datafile;
|
|
147
354
|
ready;
|
|
148
355
|
destroyed;
|
|
149
356
|
tracker;
|
|
357
|
+
streamingClient;
|
|
150
358
|
pollingId;
|
|
359
|
+
abortController = new AbortController();
|
|
151
360
|
overrides = /* @__PURE__ */ new Map();
|
|
152
361
|
listeners = /* @__PURE__ */ new Map();
|
|
153
362
|
readyPromise = null;
|
|
363
|
+
pendingEvents = [];
|
|
364
|
+
/**
|
|
365
|
+
* §6 — `client.utils.*` surface. Constructed lazily on first access so
|
|
366
|
+
* the utils package is only imported when actually consumed.
|
|
367
|
+
*
|
|
368
|
+
* The server's event surface (`ServerEventMap`) is a subset of the
|
|
369
|
+
* shared `ClientEventMap`; the utils-adapter `on` here ignores events
|
|
370
|
+
* the server doesn't emit (e.g. `flagChange`). Workstream 5 (CoW 6)
|
|
371
|
+
* harmonises the two maps when the streaming evaluator lands.
|
|
372
|
+
*/
|
|
373
|
+
_utils = null;
|
|
374
|
+
get utils() {
|
|
375
|
+
if (!this._utils) {
|
|
376
|
+
this._utils = buildServerUtils({
|
|
377
|
+
getAllFlags: (ctx, opts) => this.getAllFlags(ctx, opts),
|
|
378
|
+
getFlag: (flagKey, defaultValue, opts) => this.getFlag(flagKey, defaultValue, opts),
|
|
379
|
+
track: (eventKey, payload) => this.track(eventKey, payload),
|
|
380
|
+
on: (event, cb) => {
|
|
381
|
+
if (event === "ready" || event === "configUpdate" || event === "evaluation" || event === "error" || event === "stickyOrphan") {
|
|
382
|
+
return this.on(
|
|
383
|
+
event,
|
|
384
|
+
cb
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return () => void 0;
|
|
388
|
+
},
|
|
389
|
+
getDatafile: () => this.datafile
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return this._utils;
|
|
393
|
+
}
|
|
154
394
|
constructor(options) {
|
|
155
395
|
this.sdkKey = options.sdkKey;
|
|
156
396
|
this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
|
|
@@ -160,12 +400,18 @@ var AvsBServer = class {
|
|
|
160
400
|
this.logger = options.logger ?? noopLogger;
|
|
161
401
|
this.stickyBucketService = options.stickyBucketService ?? null;
|
|
162
402
|
this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
|
|
403
|
+
this.streamingEnabled = options.streaming ?? false;
|
|
404
|
+
this.streamingEndpoint = options.streamingEndpoint ?? DEFAULT_STREAMING_ENDPOINT;
|
|
163
405
|
this.datafile = options.bootstrap ?? null;
|
|
164
406
|
this.ready = !!options.bootstrap;
|
|
165
407
|
this.destroyed = false;
|
|
166
408
|
this.tracker = null;
|
|
409
|
+
this.streamingClient = null;
|
|
167
410
|
this.pollingId = null;
|
|
168
411
|
}
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// §5.4 onReady — never rejects, always resolves to InitResult
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
169
415
|
onReady() {
|
|
170
416
|
if (this.readyPromise) return this.readyPromise;
|
|
171
417
|
this.readyPromise = this.initializeAndReady();
|
|
@@ -175,6 +421,7 @@ var AvsBServer = class {
|
|
|
175
421
|
if (this.ready && this.datafile) {
|
|
176
422
|
this.initTracker();
|
|
177
423
|
this.startPolling();
|
|
424
|
+
this.startStreamingIfEnabled();
|
|
178
425
|
this.sendHeartbeat();
|
|
179
426
|
this.emit("ready");
|
|
180
427
|
return { success: true, source: "bootstrap" };
|
|
@@ -188,6 +435,7 @@ var AvsBServer = class {
|
|
|
188
435
|
]);
|
|
189
436
|
if (result.timedOut) {
|
|
190
437
|
this.startPolling();
|
|
438
|
+
this.startStreamingIfEnabled();
|
|
191
439
|
const error2 = new Error(`SDK initialization timed out after ${this.initTimeout}ms`);
|
|
192
440
|
this.emit("error", error2);
|
|
193
441
|
return { success: false, source: "timeout", error: error2 };
|
|
@@ -197,23 +445,34 @@ var AvsBServer = class {
|
|
|
197
445
|
this.initTracker();
|
|
198
446
|
this.ready = true;
|
|
199
447
|
this.startPolling();
|
|
448
|
+
this.startStreamingIfEnabled();
|
|
200
449
|
this.sendHeartbeat();
|
|
201
450
|
this.emit("ready");
|
|
202
451
|
return { success: true, source: "network" };
|
|
203
452
|
}
|
|
204
453
|
this.startPolling();
|
|
454
|
+
this.startStreamingIfEnabled();
|
|
205
455
|
const error = new Error("Failed to fetch datafile");
|
|
206
456
|
this.emit("error", error);
|
|
207
457
|
return { success: false, source: "error", error };
|
|
208
458
|
} catch (e) {
|
|
209
459
|
this.startPolling();
|
|
460
|
+
this.startStreamingIfEnabled();
|
|
210
461
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
211
462
|
this.emit("error", error);
|
|
212
463
|
return { success: false, source: "error", error };
|
|
213
464
|
}
|
|
214
465
|
}
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// Lifecycle
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
215
469
|
async close() {
|
|
216
470
|
this.destroyed = true;
|
|
471
|
+
this.abortController.abort();
|
|
472
|
+
if (this.streamingClient) {
|
|
473
|
+
this.streamingClient.disconnect();
|
|
474
|
+
this.streamingClient = null;
|
|
475
|
+
}
|
|
217
476
|
if (this.pollingId) {
|
|
218
477
|
clearInterval(this.pollingId);
|
|
219
478
|
this.pollingId = null;
|
|
@@ -222,6 +481,7 @@ var AvsBServer = class {
|
|
|
222
481
|
await this.tracker.destroy();
|
|
223
482
|
this.tracker = null;
|
|
224
483
|
}
|
|
484
|
+
this._utils?._drain();
|
|
225
485
|
this.datafile = null;
|
|
226
486
|
this.ready = false;
|
|
227
487
|
}
|
|
@@ -245,125 +505,204 @@ var AvsBServer = class {
|
|
|
245
505
|
this.logger.info("Datafile updated", { publishedAt: df.publishedAt });
|
|
246
506
|
}
|
|
247
507
|
}
|
|
248
|
-
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// §5.3 getFlag — required defaultValue, returns Flag<T>
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
getFlag(flagKey, defaultValue, options) {
|
|
512
|
+
const context = options?.context;
|
|
513
|
+
const decisionLog = options?.decisionLog;
|
|
514
|
+
if (!context) {
|
|
515
|
+
this.logger.warn(
|
|
516
|
+
`getFlag called without context for flagKey="${flagKey}" \u2014 returning default`
|
|
517
|
+
);
|
|
518
|
+
return notFoundFlag2(defaultValue, "no context provided");
|
|
519
|
+
}
|
|
249
520
|
if (!this.ready || !this.datafile) {
|
|
250
521
|
this.logger.debug(`SDK not ready, returning default for ${flagKey}`);
|
|
251
|
-
return defaultValue;
|
|
522
|
+
return notFoundFlag2(defaultValue, "SDK not ready");
|
|
252
523
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return result.value ?? defaultValue;
|
|
256
|
-
}
|
|
257
|
-
getFlagDetails(flagKey, userContext) {
|
|
258
|
-
if (!this.ready || !this.datafile) {
|
|
259
|
-
return {
|
|
260
|
-
value: void 0,
|
|
261
|
-
variationKey: null,
|
|
262
|
-
source: "not_found",
|
|
263
|
-
ruleId: null,
|
|
264
|
-
ruleType: null,
|
|
524
|
+
if (options?.forcedVariationKey) {
|
|
525
|
+
return this.buildFlagFromVariationKey(
|
|
265
526
|
flagKey,
|
|
266
|
-
|
|
267
|
-
|
|
527
|
+
options.forcedVariationKey,
|
|
528
|
+
defaultValue,
|
|
529
|
+
"runtimeOverride",
|
|
530
|
+
context,
|
|
531
|
+
decisionLog
|
|
532
|
+
);
|
|
268
533
|
}
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
534
|
+
const legacyCtx = this.buildLegacyContext(context, options);
|
|
535
|
+
const result = this.evaluate(flagKey, legacyCtx, defaultValue);
|
|
536
|
+
this.emitEvaluation(flagKey, result.value, bucketingKey(context), result.source);
|
|
537
|
+
return this.toFlag(result, defaultValue, flagKey, context, decisionLog);
|
|
538
|
+
}
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// §5.11 Typed evaluator variants
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
getBoolFlag(flagKey, defaultValue, options) {
|
|
543
|
+
return this.getTypedFlag(flagKey, defaultValue, "boolean", options);
|
|
279
544
|
}
|
|
280
|
-
|
|
545
|
+
getStringFlag(flagKey, defaultValue, options) {
|
|
546
|
+
return this.getTypedFlag(flagKey, defaultValue, "string", options);
|
|
547
|
+
}
|
|
548
|
+
getNumberFlag(flagKey, defaultValue, options) {
|
|
549
|
+
return this.getTypedFlag(flagKey, defaultValue, "number", options);
|
|
550
|
+
}
|
|
551
|
+
getJsonFlag(flagKey, defaultValue, options) {
|
|
552
|
+
return this.getTypedFlag(flagKey, defaultValue, "json", options);
|
|
553
|
+
}
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
// §5.7 getAllFlags — returns Record<string, Flag>
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
getAllFlags(context, options) {
|
|
281
558
|
if (!this.ready || !this.datafile) return {};
|
|
559
|
+
const fireExposures = options?.fireExposures === true;
|
|
282
560
|
const out = {};
|
|
283
|
-
for (const
|
|
561
|
+
for (const flagEntry of this.datafile.flags) {
|
|
562
|
+
const flagOptions = {
|
|
563
|
+
context,
|
|
564
|
+
decideOptions: options?.decideOptions
|
|
565
|
+
};
|
|
566
|
+
if (!fireExposures) {
|
|
567
|
+
const suppressCtx = this.buildLegacyContext(context, flagOptions);
|
|
568
|
+
suppressCtx.suppressExposure = true;
|
|
569
|
+
const result = this.evaluate(flagEntry.key, suppressCtx, void 0);
|
|
570
|
+
out[flagEntry.key] = this.toFlagNoLog(result, void 0, flagEntry.key);
|
|
571
|
+
} else {
|
|
572
|
+
out[flagEntry.key] = this.getFlag(flagEntry.key, void 0, flagOptions);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
284
575
|
return out;
|
|
285
576
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// Purchase tracking — fires immediately to /v1/collect/orders
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
async trackPurchase(visitorId, order) {
|
|
581
|
+
if (!this.tracker) {
|
|
582
|
+
this.logger.warn("trackPurchase called before tracker is ready; order dropped");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
return this.tracker.trackPurchase(visitorId, order);
|
|
586
|
+
}
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// §5.6 Unified track — context required
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
track(eventKey, payload) {
|
|
591
|
+
const context = payload.context;
|
|
592
|
+
const userId = bucketingKey(context);
|
|
593
|
+
if (!this.tracker) {
|
|
594
|
+
this.pendingEvents.push({ eventKey, payload });
|
|
289
595
|
return;
|
|
290
596
|
}
|
|
291
597
|
this.tracker.queueMetric({
|
|
292
|
-
visitorId:
|
|
598
|
+
visitorId: userId,
|
|
293
599
|
eventName: eventKey,
|
|
294
|
-
revenue:
|
|
600
|
+
revenue: payload.value ?? null,
|
|
295
601
|
timestamp: Date.now(),
|
|
296
|
-
pageUrl: ""
|
|
602
|
+
pageUrl: "",
|
|
603
|
+
...payload.properties ? { properties: payload.properties } : {}
|
|
297
604
|
});
|
|
298
605
|
}
|
|
299
|
-
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// §5.13 manualExposure — updated to EvalContext
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
manualExposure(flagKey, context) {
|
|
300
610
|
if (!this.ready || !this.datafile || !this.tracker) return;
|
|
301
|
-
const
|
|
302
|
-
const result =
|
|
303
|
-
if (result.source !== "rule" || !result.ruleId) return;
|
|
611
|
+
const legacyCtx = this.buildLegacyContext(context, { suppressExposure: true });
|
|
612
|
+
const result = this.evaluate(flagKey, legacyCtx, void 0);
|
|
613
|
+
if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
|
|
614
|
+
if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
|
|
304
615
|
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
305
616
|
if (!flag) return;
|
|
306
|
-
const
|
|
307
|
-
if (!
|
|
308
|
-
const
|
|
309
|
-
const fv = flag.variations.find((x) => x.id === v.variationId);
|
|
310
|
-
return fv?.key === result.variationKey;
|
|
311
|
-
});
|
|
312
|
-
if (!rv) return;
|
|
617
|
+
const variation = flag.variations.find((v) => v.key === result.variationKey);
|
|
618
|
+
if (!variation) return;
|
|
619
|
+
const userId = bucketingKey(context);
|
|
313
620
|
this.tracker.queue({
|
|
314
|
-
visitorId:
|
|
621
|
+
visitorId: userId,
|
|
315
622
|
experimentId: result.ruleId,
|
|
316
|
-
variationId:
|
|
623
|
+
variationId: variation.id,
|
|
317
624
|
eventName: "flag_exposure",
|
|
318
625
|
revenue: null,
|
|
319
626
|
timestamp: Date.now(),
|
|
320
627
|
pageUrl: "",
|
|
321
|
-
properties: { flagKey, flagId: flag.id }
|
|
628
|
+
properties: { flagKey, flagId: flag.id, manuallyFired: true }
|
|
322
629
|
});
|
|
323
630
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
// §5.8 forUser / createUserContext — UserBoundClient factory
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
forUser(context) {
|
|
635
|
+
return createUserBoundClient(this, context);
|
|
636
|
+
}
|
|
637
|
+
createUserContext(context) {
|
|
638
|
+
return createUserBoundClient(this, context);
|
|
639
|
+
}
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// §5.9 Override methods — restructured storage
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
setOverrideForUser(flagKey, contextKey, variationKey, contextKind = "user") {
|
|
644
|
+
if (!this.overrides.has(flagKey)) {
|
|
645
|
+
this.overrides.set(flagKey, { perKindKey: /* @__PURE__ */ new Map() });
|
|
646
|
+
}
|
|
647
|
+
const entry = this.overrides.get(flagKey);
|
|
648
|
+
entry.perKindKey.set(`${contextKind}:${contextKey}`, variationKey);
|
|
649
|
+
}
|
|
650
|
+
setGlobalOverride(flagKey, variationKey) {
|
|
651
|
+
if (!this.overrides.has(flagKey)) {
|
|
652
|
+
this.overrides.set(flagKey, { perKindKey: /* @__PURE__ */ new Map() });
|
|
653
|
+
}
|
|
654
|
+
this.overrides.get(flagKey).global = variationKey;
|
|
655
|
+
}
|
|
656
|
+
clearOverrideForUser(flagKey, contextKey, contextKind = "user") {
|
|
657
|
+
const entry = this.overrides.get(flagKey);
|
|
658
|
+
if (!entry) return;
|
|
659
|
+
entry.perKindKey.delete(`${contextKind}:${contextKey}`);
|
|
660
|
+
if (!entry.global && entry.perKindKey.size === 0) {
|
|
661
|
+
this.overrides.delete(flagKey);
|
|
662
|
+
}
|
|
327
663
|
}
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
if (!
|
|
331
|
-
|
|
332
|
-
if (
|
|
664
|
+
clearGlobalOverride(flagKey) {
|
|
665
|
+
const entry = this.overrides.get(flagKey);
|
|
666
|
+
if (!entry) return;
|
|
667
|
+
entry.global = void 0;
|
|
668
|
+
if (entry.perKindKey.size === 0) {
|
|
669
|
+
this.overrides.delete(flagKey);
|
|
670
|
+
}
|
|
333
671
|
}
|
|
334
672
|
clearAllOverrides() {
|
|
335
673
|
this.overrides.clear();
|
|
336
674
|
}
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
// Decision log factory (convenience)
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
337
678
|
createDecisionLog() {
|
|
338
679
|
return createDecisionLog();
|
|
339
680
|
}
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// Event listener API — on() returns unsubscribe; off() kept for compat
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
340
684
|
on(event, cb) {
|
|
341
685
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
342
686
|
this.listeners.get(event).add(cb);
|
|
687
|
+
return () => {
|
|
688
|
+
this.listeners.get(event)?.delete(cb);
|
|
689
|
+
};
|
|
343
690
|
}
|
|
344
691
|
off(event, cb) {
|
|
345
692
|
this.listeners.get(event)?.delete(cb);
|
|
346
693
|
}
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// Private — core evaluate
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
347
697
|
evaluate(flagKey, uc, defaultValue) {
|
|
348
|
-
const startTime = Date.now();
|
|
349
698
|
const reasons = [];
|
|
350
699
|
const rtOver = this.resolveRuntimeOverride(flagKey, uc.userId);
|
|
351
700
|
if (rtOver !== void 0) {
|
|
352
701
|
reasons.push(`matched runtime override for flagKey="${flagKey}"`);
|
|
353
|
-
this.recordDecisionLog(uc, {
|
|
354
|
-
flagKey,
|
|
355
|
-
value: rtOver.value,
|
|
356
|
-
variationKey: rtOver.key,
|
|
357
|
-
source: "runtime_override",
|
|
358
|
-
ruleId: null,
|
|
359
|
-
ruleType: null,
|
|
360
|
-
reasons,
|
|
361
|
-
startTime
|
|
362
|
-
});
|
|
363
702
|
return {
|
|
364
703
|
value: rtOver.value,
|
|
365
704
|
variationKey: rtOver.key,
|
|
366
|
-
source: "
|
|
705
|
+
source: "runtimeOverride",
|
|
367
706
|
ruleId: null,
|
|
368
707
|
ruleType: null,
|
|
369
708
|
reasons
|
|
@@ -373,31 +712,21 @@ var AvsBServer = class {
|
|
|
373
712
|
const sticky = this.resolveStickyBucket(flagKey, uc.userId);
|
|
374
713
|
if (sticky !== void 0) {
|
|
375
714
|
reasons.push(`matched sticky bucket assignment for userId="${uc.userId}"`);
|
|
376
|
-
if (!uc.suppressExposure)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
value: sticky.value,
|
|
380
|
-
variationKey: sticky.key,
|
|
381
|
-
source: "sticky",
|
|
382
|
-
ruleId: null,
|
|
383
|
-
ruleType: null,
|
|
384
|
-
reasons,
|
|
385
|
-
startTime
|
|
386
|
-
});
|
|
715
|
+
if (!uc.suppressExposure) {
|
|
716
|
+
this.fireExposureForStickyAssignment(flagKey, sticky, uc);
|
|
717
|
+
}
|
|
387
718
|
return {
|
|
388
|
-
value: sticky.value,
|
|
389
|
-
variationKey: sticky.key,
|
|
719
|
+
value: sticky.vref.value,
|
|
720
|
+
variationKey: sticky.vref.key,
|
|
390
721
|
source: "sticky",
|
|
391
|
-
ruleId:
|
|
392
|
-
ruleType:
|
|
722
|
+
ruleId: sticky.assignment.ruleId,
|
|
723
|
+
ruleType: sticky.assignment.ruleType,
|
|
393
724
|
reasons
|
|
394
725
|
};
|
|
395
726
|
}
|
|
396
727
|
}
|
|
397
|
-
const
|
|
398
|
-
const shouldFire = !uc.suppressExposure
|
|
399
|
-
(e) => e.flagKey === flagKey && e.source !== "not_found" && e.source !== "disabled"
|
|
400
|
-
);
|
|
728
|
+
const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
|
|
729
|
+
const shouldFire = !uc.suppressExposure;
|
|
401
730
|
const onExposure = shouldFire ? (event) => {
|
|
402
731
|
if (uc.privateAttributes?.length && event.properties) {
|
|
403
732
|
const cleaned = { ...event.properties };
|
|
@@ -406,41 +735,216 @@ var AvsBServer = class {
|
|
|
406
735
|
}
|
|
407
736
|
this.tracker?.queue(event);
|
|
408
737
|
} : void 0;
|
|
409
|
-
const r = evaluateFlag(
|
|
410
|
-
|
|
738
|
+
const r = evaluateFlag(
|
|
739
|
+
flagKey,
|
|
740
|
+
defaultValue,
|
|
741
|
+
legacyCtx,
|
|
742
|
+
this.datafile,
|
|
743
|
+
onExposure,
|
|
744
|
+
reasons
|
|
745
|
+
);
|
|
746
|
+
if (this.stickyBucketService && r.variationKey && r.source === "rule" && r.ruleId && r.ruleType === "ab_test") {
|
|
411
747
|
const v = this.findVariation(flagKey, r.variationKey);
|
|
412
|
-
if (v)
|
|
748
|
+
if (v) {
|
|
749
|
+
this.stickyBucketService.save(uc.userId, flagKey, {
|
|
750
|
+
variationId: v.id,
|
|
751
|
+
ruleId: r.ruleId,
|
|
752
|
+
ruleType: r.ruleType,
|
|
753
|
+
assignedAt: Date.now()
|
|
754
|
+
});
|
|
755
|
+
}
|
|
413
756
|
}
|
|
414
|
-
this.recordDecisionLog(uc, {
|
|
415
|
-
flagKey,
|
|
416
|
-
value: r.value,
|
|
417
|
-
variationKey: r.variationKey,
|
|
418
|
-
source: r.source,
|
|
419
|
-
ruleId: r.ruleId,
|
|
420
|
-
ruleType: r.ruleType,
|
|
421
|
-
reasons,
|
|
422
|
-
startTime
|
|
423
|
-
});
|
|
424
757
|
return {
|
|
425
758
|
value: r.value,
|
|
426
759
|
variationKey: r.variationKey,
|
|
427
|
-
source: r.source,
|
|
760
|
+
source: mapLegacySource(r.source),
|
|
428
761
|
ruleId: r.ruleId,
|
|
429
762
|
ruleType: r.ruleType,
|
|
430
763
|
reasons
|
|
431
764
|
};
|
|
432
765
|
}
|
|
766
|
+
// ---------------------------------------------------------------------------
|
|
767
|
+
// Private — typed evaluation helper
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
getTypedFlag(flagKey, defaultValue, expectedType, options) {
|
|
770
|
+
const flag = this.getFlag(flagKey, defaultValue, options);
|
|
771
|
+
if (flag.source === "not_found" || flag.source === "disabled") {
|
|
772
|
+
return flag;
|
|
773
|
+
}
|
|
774
|
+
const datafileFlag = findDatafileFlag(this.datafile, flagKey);
|
|
775
|
+
const mismatch = checkExpectedType(datafileFlag, expectedType, flag.value);
|
|
776
|
+
if (mismatch) {
|
|
777
|
+
this.logger.warn(
|
|
778
|
+
`Type mismatch on flag "${flagKey}": ${mismatch} \u2014 returning default`
|
|
779
|
+
);
|
|
780
|
+
return notFoundFlag2(defaultValue, mismatch);
|
|
781
|
+
}
|
|
782
|
+
return flag;
|
|
783
|
+
}
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
// Private — build Flag<T> from an EvalResult
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
toFlag(result, defaultValue, flagKey, context, decisionLog, startMicros) {
|
|
788
|
+
const evaluatedAt = Date.now();
|
|
789
|
+
const durationMicros = startMicros !== void 0 ? performance.now() * 1e3 - startMicros : 0;
|
|
790
|
+
const flag = createFlag({
|
|
791
|
+
value: result.value ?? defaultValue,
|
|
792
|
+
variationKey: result.variationKey,
|
|
793
|
+
source: result.source,
|
|
794
|
+
ruleId: result.ruleId,
|
|
795
|
+
ruleType: result.ruleType,
|
|
796
|
+
reasons: result.reasons,
|
|
797
|
+
evaluatedAt,
|
|
798
|
+
durationMicros: Math.round(durationMicros)
|
|
799
|
+
});
|
|
800
|
+
if (decisionLog) {
|
|
801
|
+
const privateAttrs = privateAttributesFrom(context);
|
|
802
|
+
const summary = contextSummary(context);
|
|
803
|
+
const redactedValue = privateAttrs.size > 0 && typeof result.value === "string" && privateAttrs.has(result.value) ? "[REDACTED]" : result.value;
|
|
804
|
+
decisionLog.entries.push({
|
|
805
|
+
flagKey,
|
|
806
|
+
value: redactedValue,
|
|
807
|
+
variationKey: result.variationKey,
|
|
808
|
+
source: result.source,
|
|
809
|
+
ruleId: result.ruleId,
|
|
810
|
+
ruleType: result.ruleType,
|
|
811
|
+
reasons: [...result.reasons],
|
|
812
|
+
evaluatedAt,
|
|
813
|
+
durationMicros: Math.round(durationMicros),
|
|
814
|
+
contextSummary: summary
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
return flag;
|
|
818
|
+
}
|
|
819
|
+
/** toFlag variant that never writes a decision log (used by getAllFlags suppressed path). */
|
|
820
|
+
toFlagNoLog(result, defaultValue, _flagKey) {
|
|
821
|
+
return createFlag({
|
|
822
|
+
value: result.value ?? defaultValue,
|
|
823
|
+
variationKey: result.variationKey,
|
|
824
|
+
source: result.source,
|
|
825
|
+
ruleId: result.ruleId,
|
|
826
|
+
ruleType: result.ruleType,
|
|
827
|
+
reasons: result.reasons,
|
|
828
|
+
evaluatedAt: Date.now(),
|
|
829
|
+
durationMicros: 0
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
/** Build a Flag<T> from a forced variation key (per-request override). */
|
|
833
|
+
buildFlagFromVariationKey(flagKey, variationKey, defaultValue, source, context, decisionLog) {
|
|
834
|
+
const vref = this.findVariation(flagKey, variationKey);
|
|
835
|
+
const value = vref ? vref.value : defaultValue;
|
|
836
|
+
const result = {
|
|
837
|
+
value,
|
|
838
|
+
variationKey: vref ? vref.key : null,
|
|
839
|
+
source,
|
|
840
|
+
ruleId: null,
|
|
841
|
+
ruleType: null,
|
|
842
|
+
reasons: [`forced decision: variationKey="${variationKey}"`]
|
|
843
|
+
};
|
|
844
|
+
return this.toFlag(result, defaultValue, flagKey, context, decisionLog);
|
|
845
|
+
}
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
// Private — build LegacyUserContext from EvalContext
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
buildLegacyContext(context, options) {
|
|
850
|
+
const legacy = toLegacyEvalContext(context);
|
|
851
|
+
const userId = legacy.attributes["userId"] ?? "";
|
|
852
|
+
const privateAttrs = privateAttributesFrom(context);
|
|
853
|
+
const suppressExposure = options?.suppressExposure === true || options?.decideOptions?.includes("DISABLE_EXPOSURE") === true;
|
|
854
|
+
const { userId: _uid, ...rest } = legacy.attributes;
|
|
855
|
+
return {
|
|
856
|
+
userId,
|
|
857
|
+
attributes: rest,
|
|
858
|
+
suppressExposure,
|
|
859
|
+
privateAttributes: privateAttrs.size > 0 ? [...privateAttrs] : void 0
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
// ---------------------------------------------------------------------------
|
|
863
|
+
// Private — override resolution
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
resolveRuntimeOverride(flagKey, userId) {
|
|
866
|
+
const entry = this.overrides.get(flagKey);
|
|
867
|
+
if (!entry) return void 0;
|
|
868
|
+
const userVk = entry.perKindKey.get(`user:${userId}`);
|
|
869
|
+
const vk = userVk ?? entry.global;
|
|
870
|
+
if (!vk) return void 0;
|
|
871
|
+
return this.findVariation(flagKey, vk) ?? void 0;
|
|
872
|
+
}
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
// Private — sticky bucket helpers
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
resolveStickyBucket(flagKey, userId) {
|
|
877
|
+
if (!this.stickyBucketService || !this.datafile) return void 0;
|
|
878
|
+
const assignment = this.stickyBucketService.lookup(userId, flagKey);
|
|
879
|
+
if (!assignment) return void 0;
|
|
880
|
+
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
881
|
+
if (!flag) return void 0;
|
|
882
|
+
const v = flag.variations.find((x) => x.id === assignment.variationId);
|
|
883
|
+
if (!v) return void 0;
|
|
884
|
+
return { vref: { value: v.value, key: v.key, id: v.id }, assignment };
|
|
885
|
+
}
|
|
886
|
+
findVariation(flagKey, variationKey) {
|
|
887
|
+
if (!this.datafile) return null;
|
|
888
|
+
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
889
|
+
if (!flag) return null;
|
|
890
|
+
const v = flag.variations.find((x) => x.key === variationKey);
|
|
891
|
+
if (!v) return null;
|
|
892
|
+
return { value: v.value, key: v.key, id: v.id };
|
|
893
|
+
}
|
|
894
|
+
fireExposureForStickyAssignment(flagKey, sticky, uc) {
|
|
895
|
+
if (!this.tracker || !this.datafile) return;
|
|
896
|
+
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
897
|
+
if (!flag) return;
|
|
898
|
+
const rule = flag.rules.find((r) => r.id === sticky.assignment.ruleId);
|
|
899
|
+
if (!rule || !rule.variations.some((rv) => rv.variationId === sticky.vref.id)) {
|
|
900
|
+
this.logger.warn(
|
|
901
|
+
"Sticky assignment references a rule no longer matching the datafile",
|
|
902
|
+
{
|
|
903
|
+
flagKey,
|
|
904
|
+
userId: uc.userId,
|
|
905
|
+
assignedRuleId: sticky.assignment.ruleId,
|
|
906
|
+
assignedVariationId: sticky.vref.id
|
|
907
|
+
}
|
|
908
|
+
);
|
|
909
|
+
this.emit("stickyOrphan", {
|
|
910
|
+
flagKey,
|
|
911
|
+
userId: uc.userId,
|
|
912
|
+
assignment: sticky.assignment
|
|
913
|
+
});
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
this.tracker.queue({
|
|
917
|
+
visitorId: uc.userId,
|
|
918
|
+
experimentId: rule.id,
|
|
919
|
+
variationId: sticky.vref.id,
|
|
920
|
+
eventName: "flag_exposure",
|
|
921
|
+
revenue: null,
|
|
922
|
+
timestamp: Date.now(),
|
|
923
|
+
pageUrl: "",
|
|
924
|
+
properties: { flagKey, flagId: flag.id }
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
// Private — event emission
|
|
929
|
+
// ---------------------------------------------------------------------------
|
|
433
930
|
emit(event, ...args) {
|
|
434
931
|
const cbs = this.listeners.get(event);
|
|
435
932
|
if (!cbs) return;
|
|
436
933
|
for (const cb of cbs) {
|
|
437
934
|
try {
|
|
935
|
+
;
|
|
438
936
|
cb(...args);
|
|
439
937
|
} catch (e) {
|
|
440
938
|
this.logger.error("Listener error", { event, error: String(e) });
|
|
441
939
|
}
|
|
442
940
|
}
|
|
443
941
|
}
|
|
942
|
+
emitEvaluation(flagKey, value, userId, source) {
|
|
943
|
+
this.emit("evaluation", { flagKey, value, userId, source });
|
|
944
|
+
}
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
// Private — tracker + polling + streaming
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
444
948
|
initTracker() {
|
|
445
949
|
if (!this.datafile || this.tracker) return;
|
|
446
950
|
this.tracker = createServerTracker({
|
|
@@ -452,17 +956,37 @@ var AvsBServer = class {
|
|
|
452
956
|
retryAttempts: this.eventOptions.retryAttempts,
|
|
453
957
|
logger: this.logger
|
|
454
958
|
});
|
|
959
|
+
if (this.pendingEvents.length > 0) {
|
|
960
|
+
const queued = this.pendingEvents;
|
|
961
|
+
this.pendingEvents = [];
|
|
962
|
+
for (const item of queued) {
|
|
963
|
+
this.track(item.eventKey, item.payload);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
startStreamingIfEnabled() {
|
|
968
|
+
if (!this.streamingEnabled || this.streamingClient || this.destroyed) return;
|
|
969
|
+
this.streamingClient = createStreamingClient({
|
|
970
|
+
endpoint: this.streamingEndpoint,
|
|
971
|
+
sdkKey: this.sdkKey,
|
|
972
|
+
onDatafileUpdated: () => {
|
|
973
|
+
void this.refreshDatafile();
|
|
974
|
+
},
|
|
975
|
+
logger: this.logger
|
|
976
|
+
});
|
|
977
|
+
this.streamingClient.connect();
|
|
455
978
|
}
|
|
456
979
|
async fetchDatafile() {
|
|
457
980
|
const url = `${this.cdnHost}/${this.sdkKey}/datafile.json`;
|
|
458
981
|
try {
|
|
459
|
-
const res = await fetch(url, { signal:
|
|
982
|
+
const res = await fetch(url, { signal: this.abortController.signal });
|
|
460
983
|
if (!res.ok) {
|
|
461
984
|
this.logger.warn("Datafile fetch failed", { status: res.status, url });
|
|
462
985
|
return null;
|
|
463
986
|
}
|
|
464
987
|
return await res.json();
|
|
465
988
|
} catch (e) {
|
|
989
|
+
if (this.abortController.signal.aborted) return null;
|
|
466
990
|
this.logger.warn("Datafile fetch error", { error: String(e), url });
|
|
467
991
|
return null;
|
|
468
992
|
}
|
|
@@ -490,96 +1014,58 @@ var AvsBServer = class {
|
|
|
490
1014
|
fetch(this.datafile.heartbeatEndpoint, {
|
|
491
1015
|
method: "POST",
|
|
492
1016
|
headers: { "Content-Type": "application/json" },
|
|
493
|
-
body: JSON.stringify({
|
|
1017
|
+
body: JSON.stringify({
|
|
1018
|
+
sdkKey: this.sdkKey,
|
|
1019
|
+
sdkType: "@avsbhq/node",
|
|
1020
|
+
sdkVersion: SDK_VERSION
|
|
1021
|
+
}),
|
|
1022
|
+
signal: this.abortController.signal
|
|
494
1023
|
}).catch(() => {
|
|
495
1024
|
});
|
|
496
1025
|
}
|
|
497
|
-
resolveRuntimeOverride(flagKey, userId) {
|
|
498
|
-
const m = this.overrides.get(flagKey);
|
|
499
|
-
if (!m) return void 0;
|
|
500
|
-
const vk = m.get(userId) ?? m.get("*");
|
|
501
|
-
if (!vk) return void 0;
|
|
502
|
-
return this.findVariation(flagKey, vk) ?? void 0;
|
|
503
|
-
}
|
|
504
|
-
resolveStickyBucket(flagKey, userId) {
|
|
505
|
-
if (!this.stickyBucketService || !this.datafile) return void 0;
|
|
506
|
-
const vid = this.stickyBucketService.lookup(userId, flagKey);
|
|
507
|
-
if (!vid) return void 0;
|
|
508
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
509
|
-
if (!flag) return void 0;
|
|
510
|
-
const v = flag.variations.find((x) => x.id === vid);
|
|
511
|
-
if (!v) return void 0;
|
|
512
|
-
return { value: v.value, key: v.key, id: v.id };
|
|
513
|
-
}
|
|
514
|
-
findVariation(flagKey, variationKey) {
|
|
515
|
-
if (!this.datafile) return null;
|
|
516
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
517
|
-
if (!flag) return null;
|
|
518
|
-
const v = flag.variations.find((x) => x.key === variationKey);
|
|
519
|
-
if (!v) return null;
|
|
520
|
-
return { value: v.value, key: v.key, id: v.id };
|
|
521
|
-
}
|
|
522
|
-
fireExposureForVariation(flagKey, vr, uc) {
|
|
523
|
-
if (!this.tracker || !this.datafile) return;
|
|
524
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
525
|
-
if (!flag) return;
|
|
526
|
-
for (const rule of flag.rules) {
|
|
527
|
-
if (rule.type !== "ab_test") continue;
|
|
528
|
-
if (rule.variations.some((rv) => rv.variationId === vr.id)) {
|
|
529
|
-
this.tracker.queue({
|
|
530
|
-
visitorId: uc.userId,
|
|
531
|
-
experimentId: rule.id,
|
|
532
|
-
variationId: vr.id,
|
|
533
|
-
eventName: "flag_exposure",
|
|
534
|
-
revenue: null,
|
|
535
|
-
timestamp: Date.now(),
|
|
536
|
-
pageUrl: "",
|
|
537
|
-
properties: { flagKey, flagId: flag.id }
|
|
538
|
-
});
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
recordDecisionLog(uc, d) {
|
|
544
|
-
if (!uc.decisionLog) return;
|
|
545
|
-
uc.decisionLog.entries.push({
|
|
546
|
-
flagKey: d.flagKey,
|
|
547
|
-
value: d.value,
|
|
548
|
-
variationKey: d.variationKey,
|
|
549
|
-
source: d.source,
|
|
550
|
-
ruleId: d.ruleId,
|
|
551
|
-
ruleType: d.ruleType,
|
|
552
|
-
reasons: [...d.reasons],
|
|
553
|
-
evaluatedAt: d.startTime,
|
|
554
|
-
durationMs: Date.now() - d.startTime
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
emitEvaluation(flagKey, value, userId, source) {
|
|
558
|
-
this.emit("evaluation", { flagKey, value, userId, source });
|
|
559
|
-
}
|
|
560
1026
|
};
|
|
561
1027
|
|
|
562
1028
|
// src/middleware.ts
|
|
563
|
-
function
|
|
1029
|
+
function createNoopUserBoundClient() {
|
|
1030
|
+
const noopFlag = (defaultValue) => ({
|
|
1031
|
+
value: defaultValue,
|
|
1032
|
+
variationKey: null,
|
|
1033
|
+
source: "not_found",
|
|
1034
|
+
ruleId: null,
|
|
1035
|
+
ruleType: null,
|
|
1036
|
+
reasons: ["no userId available"],
|
|
1037
|
+
evaluatedAt: Date.now(),
|
|
1038
|
+
durationMicros: 0,
|
|
1039
|
+
isEnabled: () => false,
|
|
1040
|
+
exists: () => false
|
|
1041
|
+
});
|
|
564
1042
|
return {
|
|
565
1043
|
getFlag(_flagKey, defaultValue) {
|
|
566
|
-
return defaultValue;
|
|
1044
|
+
return noopFlag(defaultValue);
|
|
567
1045
|
},
|
|
568
|
-
|
|
569
|
-
return
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
1046
|
+
getBoolFlag(_flagKey, defaultValue) {
|
|
1047
|
+
return noopFlag(defaultValue);
|
|
1048
|
+
},
|
|
1049
|
+
getStringFlag(_flagKey, defaultValue) {
|
|
1050
|
+
return noopFlag(defaultValue);
|
|
1051
|
+
},
|
|
1052
|
+
getNumberFlag(_flagKey, defaultValue) {
|
|
1053
|
+
return noopFlag(defaultValue);
|
|
1054
|
+
},
|
|
1055
|
+
getJsonFlag(_flagKey, defaultValue) {
|
|
1056
|
+
return noopFlag(defaultValue);
|
|
578
1057
|
},
|
|
579
1058
|
getAllFlags() {
|
|
580
1059
|
return {};
|
|
581
1060
|
},
|
|
582
1061
|
track() {
|
|
1062
|
+
},
|
|
1063
|
+
setForcedDecision() {
|
|
1064
|
+
},
|
|
1065
|
+
clearForcedDecision() {
|
|
1066
|
+
},
|
|
1067
|
+
getDecisionLog() {
|
|
1068
|
+
return { entries: [] };
|
|
583
1069
|
}
|
|
584
1070
|
};
|
|
585
1071
|
}
|
|
@@ -588,25 +1074,12 @@ function expressMiddleware(client, config) {
|
|
|
588
1074
|
const userId = config.userIdFrom(req);
|
|
589
1075
|
if (!userId) {
|
|
590
1076
|
;
|
|
591
|
-
req.avsb =
|
|
1077
|
+
req.avsb = createNoopUserBoundClient();
|
|
592
1078
|
return next();
|
|
593
1079
|
}
|
|
594
1080
|
const attributes = config.attributesFrom?.(req) ?? {};
|
|
595
|
-
const
|
|
596
|
-
req.avsb =
|
|
597
|
-
getFlag(flagKey, defaultValue) {
|
|
598
|
-
return client.getFlag(flagKey, baseContext, defaultValue);
|
|
599
|
-
},
|
|
600
|
-
getFlagDetails(flagKey) {
|
|
601
|
-
return client.getFlagDetails(flagKey, baseContext);
|
|
602
|
-
},
|
|
603
|
-
getAllFlags() {
|
|
604
|
-
return client.getAllFlags(baseContext);
|
|
605
|
-
},
|
|
606
|
-
track(eventKey, properties) {
|
|
607
|
-
client.track(eventKey, { userId, revenue: properties?.revenue, properties });
|
|
608
|
-
}
|
|
609
|
-
};
|
|
1081
|
+
const context = { kind: "user", key: userId, ...attributes };
|
|
1082
|
+
req.avsb = client.forUser(context);
|
|
610
1083
|
next();
|
|
611
1084
|
};
|
|
612
1085
|
}
|
|
@@ -619,30 +1092,20 @@ function fastifyPlugin(instance, opts, done) {
|
|
|
619
1092
|
const userId = opts.userIdFrom(req);
|
|
620
1093
|
if (!userId) {
|
|
621
1094
|
;
|
|
622
|
-
req.avsb =
|
|
1095
|
+
req.avsb = createNoopUserBoundClient();
|
|
623
1096
|
return hookDone();
|
|
624
1097
|
}
|
|
625
1098
|
const attributes = opts.attributesFrom?.(req) ?? {};
|
|
626
|
-
const
|
|
627
|
-
req.avsb =
|
|
628
|
-
getFlag(flagKey, defaultValue) {
|
|
629
|
-
return opts.client.getFlag(flagKey, baseContext, defaultValue);
|
|
630
|
-
},
|
|
631
|
-
getFlagDetails(flagKey) {
|
|
632
|
-
return opts.client.getFlagDetails(flagKey, baseContext);
|
|
633
|
-
},
|
|
634
|
-
getAllFlags() {
|
|
635
|
-
return opts.client.getAllFlags(baseContext);
|
|
636
|
-
},
|
|
637
|
-
track(eventKey, properties) {
|
|
638
|
-
opts.client.track(eventKey, { userId, revenue: properties?.revenue, properties });
|
|
639
|
-
}
|
|
640
|
-
};
|
|
1099
|
+
const context = { kind: "user", key: userId, ...attributes };
|
|
1100
|
+
req.avsb = opts.client.forUser(context);
|
|
641
1101
|
hookDone();
|
|
642
1102
|
});
|
|
643
1103
|
done();
|
|
644
1104
|
}
|
|
645
1105
|
|
|
1106
|
+
// src/logger.ts
|
|
1107
|
+
import { noopLogger as noopLogger2, createLogger, consoleLogger, consoleTransport } from "@avsbhq/core";
|
|
1108
|
+
|
|
646
1109
|
// src/stickyBucket.ts
|
|
647
1110
|
var InMemoryStickyBucketService = class {
|
|
648
1111
|
cache = /* @__PURE__ */ new Map();
|
|
@@ -652,10 +1115,15 @@ var InMemoryStickyBucketService = class {
|
|
|
652
1115
|
lookup(userId, flagKey) {
|
|
653
1116
|
return this.cache.get(this.key(userId, flagKey)) ?? null;
|
|
654
1117
|
}
|
|
655
|
-
save(userId, flagKey,
|
|
656
|
-
this.cache.set(this.key(userId, flagKey),
|
|
1118
|
+
save(userId, flagKey, assignment) {
|
|
1119
|
+
this.cache.set(this.key(userId, flagKey), assignment);
|
|
657
1120
|
}
|
|
658
1121
|
};
|
|
1122
|
+
function isStickyAssignment(value) {
|
|
1123
|
+
if (!value || typeof value !== "object") return false;
|
|
1124
|
+
const v = value;
|
|
1125
|
+
return typeof v.variationId === "string" && typeof v.ruleId === "string" && typeof v.ruleType === "string" && typeof v.assignedAt === "number";
|
|
1126
|
+
}
|
|
659
1127
|
var RedisStickyBucketService = class {
|
|
660
1128
|
redis;
|
|
661
1129
|
prefix;
|
|
@@ -668,7 +1136,7 @@ var RedisStickyBucketService = class {
|
|
|
668
1136
|
this.prefix = options.prefix ?? "avsb:sticky:";
|
|
669
1137
|
this.ttl = options.ttl ?? 2592e3;
|
|
670
1138
|
this.maxCacheSize = options.cacheSize ?? 1e4;
|
|
671
|
-
this.logger = options.logger ??
|
|
1139
|
+
this.logger = options.logger ?? noopLogger2;
|
|
672
1140
|
}
|
|
673
1141
|
redisKey(userId, flagKey) {
|
|
674
1142
|
return `${this.prefix}${userId}:${flagKey}`;
|
|
@@ -683,12 +1151,13 @@ var RedisStickyBucketService = class {
|
|
|
683
1151
|
this.backgroundFetch(userId, flagKey);
|
|
684
1152
|
return null;
|
|
685
1153
|
}
|
|
686
|
-
save(userId, flagKey,
|
|
1154
|
+
save(userId, flagKey, assignment) {
|
|
687
1155
|
const ck = this.cacheKey(userId, flagKey);
|
|
688
|
-
this.cache.set(ck,
|
|
1156
|
+
this.cache.set(ck, assignment);
|
|
689
1157
|
this.evictIfNeeded();
|
|
690
1158
|
const rk = this.redisKey(userId, flagKey);
|
|
691
|
-
|
|
1159
|
+
const serialized = JSON.stringify(assignment);
|
|
1160
|
+
this.redis.set(rk, serialized, { EX: this.ttl }).catch((e) => {
|
|
692
1161
|
this.logger.error("Redis sticky bucket save failed", {
|
|
693
1162
|
key: rk,
|
|
694
1163
|
error: String(e)
|
|
@@ -699,10 +1168,20 @@ var RedisStickyBucketService = class {
|
|
|
699
1168
|
const rk = this.redisKey(userId, flagKey);
|
|
700
1169
|
const ck = this.cacheKey(userId, flagKey);
|
|
701
1170
|
this.redis.get(rk).then((value) => {
|
|
702
|
-
if (value
|
|
703
|
-
|
|
704
|
-
|
|
1171
|
+
if (value === null) return;
|
|
1172
|
+
let parsed;
|
|
1173
|
+
try {
|
|
1174
|
+
parsed = JSON.parse(value);
|
|
1175
|
+
} catch {
|
|
1176
|
+
this.logger.warn("Redis sticky bucket value is not valid JSON", { key: rk });
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (!isStickyAssignment(parsed)) {
|
|
1180
|
+
this.logger.warn("Redis sticky bucket value missing required fields", { key: rk });
|
|
1181
|
+
return;
|
|
705
1182
|
}
|
|
1183
|
+
this.cache.set(ck, parsed);
|
|
1184
|
+
this.evictIfNeeded();
|
|
706
1185
|
}).catch((e) => {
|
|
707
1186
|
this.logger.error("Redis sticky bucket fetch failed", {
|
|
708
1187
|
key: rk,
|
|
@@ -719,112 +1198,20 @@ var RedisStickyBucketService = class {
|
|
|
719
1198
|
}
|
|
720
1199
|
};
|
|
721
1200
|
|
|
722
|
-
// src/
|
|
723
|
-
|
|
724
|
-
const {
|
|
725
|
-
endpoint,
|
|
726
|
-
sdkKey,
|
|
727
|
-
onDatafileUpdated,
|
|
728
|
-
logger,
|
|
729
|
-
reconnectDelay = 5e3
|
|
730
|
-
} = options;
|
|
731
|
-
let connected = false;
|
|
732
|
-
let abortController = null;
|
|
733
|
-
let reconnectTimer = null;
|
|
734
|
-
function connect() {
|
|
735
|
-
if (connected) return;
|
|
736
|
-
startConnection();
|
|
737
|
-
}
|
|
738
|
-
function disconnect() {
|
|
739
|
-
connected = false;
|
|
740
|
-
if (abortController) {
|
|
741
|
-
abortController.abort();
|
|
742
|
-
abortController = null;
|
|
743
|
-
}
|
|
744
|
-
if (reconnectTimer) {
|
|
745
|
-
clearTimeout(reconnectTimer);
|
|
746
|
-
reconnectTimer = null;
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
function isConnected() {
|
|
750
|
-
return connected;
|
|
751
|
-
}
|
|
752
|
-
async function startConnection() {
|
|
753
|
-
abortController = new AbortController();
|
|
754
|
-
const url = `${endpoint}?sdkKey=${encodeURIComponent(sdkKey)}`;
|
|
755
|
-
try {
|
|
756
|
-
const response = await fetch(url, {
|
|
757
|
-
headers: { Accept: "text/event-stream" },
|
|
758
|
-
signal: abortController.signal
|
|
759
|
-
});
|
|
760
|
-
if (!response.ok) {
|
|
761
|
-
logger.warn("SSE connection failed", { status: response.status, url });
|
|
762
|
-
scheduleReconnect();
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
if (!response.body) {
|
|
766
|
-
logger.warn("SSE response has no body");
|
|
767
|
-
scheduleReconnect();
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
connected = true;
|
|
771
|
-
logger.info(`SSE connected to ${endpoint}`);
|
|
772
|
-
const reader = response.body.getReader();
|
|
773
|
-
const decoder = new TextDecoder();
|
|
774
|
-
let buffer = "";
|
|
775
|
-
try {
|
|
776
|
-
while (true) {
|
|
777
|
-
const { done, value } = await reader.read();
|
|
778
|
-
if (done) break;
|
|
779
|
-
buffer += decoder.decode(value, { stream: true });
|
|
780
|
-
const lines = buffer.split("\n");
|
|
781
|
-
buffer = lines.pop() ?? "";
|
|
782
|
-
for (const line of lines) {
|
|
783
|
-
if (line.startsWith("data: ")) {
|
|
784
|
-
const data = line.slice(6).trim();
|
|
785
|
-
if (!data) continue;
|
|
786
|
-
try {
|
|
787
|
-
const parsed = JSON.parse(data);
|
|
788
|
-
if (parsed.type === "datafile_updated") {
|
|
789
|
-
onDatafileUpdated();
|
|
790
|
-
}
|
|
791
|
-
} catch {
|
|
792
|
-
logger.warn("Failed to parse SSE data", { data });
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
} catch (e) {
|
|
798
|
-
if (abortController?.signal.aborted) return;
|
|
799
|
-
logger.warn("SSE stream read error", { error: String(e) });
|
|
800
|
-
}
|
|
801
|
-
connected = false;
|
|
802
|
-
logger.warn(`SSE disconnected, reconnecting in ${reconnectDelay}ms`);
|
|
803
|
-
scheduleReconnect();
|
|
804
|
-
} catch (e) {
|
|
805
|
-
if (abortController?.signal.aborted) return;
|
|
806
|
-
logger.warn("SSE connection error", { error: String(e) });
|
|
807
|
-
connected = false;
|
|
808
|
-
scheduleReconnect();
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
function scheduleReconnect() {
|
|
812
|
-
if (reconnectTimer) return;
|
|
813
|
-
reconnectTimer = setTimeout(() => {
|
|
814
|
-
reconnectTimer = null;
|
|
815
|
-
startConnection();
|
|
816
|
-
}, reconnectDelay);
|
|
817
|
-
}
|
|
818
|
-
return { connect, disconnect, isConnected };
|
|
819
|
-
}
|
|
1201
|
+
// src/index.ts
|
|
1202
|
+
import { DecideOption } from "@avsbhq/core";
|
|
820
1203
|
export {
|
|
821
|
-
|
|
1204
|
+
AvsbServer,
|
|
1205
|
+
DecideOption,
|
|
822
1206
|
InMemoryStickyBucketService,
|
|
823
1207
|
RedisStickyBucketService,
|
|
824
1208
|
SDK_VERSION,
|
|
1209
|
+
consoleLogger,
|
|
1210
|
+
consoleTransport,
|
|
825
1211
|
createDecisionLog,
|
|
1212
|
+
createLogger,
|
|
826
1213
|
createStreamingClient,
|
|
827
1214
|
expressMiddleware,
|
|
828
1215
|
fastifyPlugin,
|
|
829
|
-
noopLogger
|
|
1216
|
+
noopLogger2 as noopLogger
|
|
830
1217
|
};
|