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