@avsbhq/node 0.1.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 +268 -0
- package/dist/index.cjs +864 -0
- package/dist/index.d.cts +246 -0
- package/dist/index.d.ts +246 -0
- package/dist/index.js +829 -0
- package/package.json +39 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
AvsBServer: () => AvsBServer,
|
|
24
|
+
InMemoryStickyBucketService: () => InMemoryStickyBucketService,
|
|
25
|
+
RedisStickyBucketService: () => RedisStickyBucketService,
|
|
26
|
+
SDK_VERSION: () => SDK_VERSION,
|
|
27
|
+
createDecisionLog: () => createDecisionLog,
|
|
28
|
+
createStreamingClient: () => createStreamingClient,
|
|
29
|
+
expressMiddleware: () => expressMiddleware,
|
|
30
|
+
fastifyPlugin: () => fastifyPlugin,
|
|
31
|
+
noopLogger: () => noopLogger
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(index_exports);
|
|
34
|
+
|
|
35
|
+
// src/AvsBServer.ts
|
|
36
|
+
var import_js = require("@avsbhq/js");
|
|
37
|
+
|
|
38
|
+
// src/logger.ts
|
|
39
|
+
var noopLogger = {
|
|
40
|
+
debug() {
|
|
41
|
+
},
|
|
42
|
+
info() {
|
|
43
|
+
},
|
|
44
|
+
warn() {
|
|
45
|
+
},
|
|
46
|
+
error() {
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// src/decisionLog.ts
|
|
51
|
+
function createDecisionLog() {
|
|
52
|
+
const entries = [];
|
|
53
|
+
return { entries };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/serverTracker.ts
|
|
57
|
+
function createServerTracker(options) {
|
|
58
|
+
const {
|
|
59
|
+
sdkKey,
|
|
60
|
+
collectEndpoint,
|
|
61
|
+
flushInterval,
|
|
62
|
+
batchSize,
|
|
63
|
+
maxQueueSize,
|
|
64
|
+
retryAttempts,
|
|
65
|
+
logger
|
|
66
|
+
} = options;
|
|
67
|
+
const batchEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/batch");
|
|
68
|
+
const metricEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/metric-batch");
|
|
69
|
+
let exposureQueue = [];
|
|
70
|
+
let metricQueue = [];
|
|
71
|
+
let flushTimer = null;
|
|
72
|
+
let destroyed = false;
|
|
73
|
+
flushTimer = setInterval(() => {
|
|
74
|
+
flush().catch((e) => {
|
|
75
|
+
logger.error("Auto-flush error", { error: String(e) });
|
|
76
|
+
});
|
|
77
|
+
}, flushInterval);
|
|
78
|
+
function queue(event) {
|
|
79
|
+
if (destroyed) return;
|
|
80
|
+
exposureQueue.push(event);
|
|
81
|
+
if (exposureQueue.length > maxQueueSize) {
|
|
82
|
+
const dropped = exposureQueue.length - maxQueueSize;
|
|
83
|
+
exposureQueue = exposureQueue.slice(-maxQueueSize);
|
|
84
|
+
logger.warn(`Event queue full (${maxQueueSize}), dropping ${dropped} oldest events`);
|
|
85
|
+
}
|
|
86
|
+
if (exposureQueue.length >= batchSize) {
|
|
87
|
+
flush().catch((e) => {
|
|
88
|
+
logger.error("Batch-triggered flush error", { error: String(e) });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function queueMetric(event) {
|
|
93
|
+
if (destroyed) return;
|
|
94
|
+
metricQueue.push(event);
|
|
95
|
+
if (metricQueue.length > maxQueueSize) {
|
|
96
|
+
const dropped = metricQueue.length - maxQueueSize;
|
|
97
|
+
metricQueue = metricQueue.slice(-maxQueueSize);
|
|
98
|
+
logger.warn(`Metric queue full (${maxQueueSize}), dropping ${dropped} oldest events`);
|
|
99
|
+
}
|
|
100
|
+
if (metricQueue.length >= batchSize) {
|
|
101
|
+
flush().catch((e) => {
|
|
102
|
+
logger.error("Batch-triggered flush error", { error: String(e) });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function flush() {
|
|
107
|
+
if (exposureQueue.length === 0 && metricQueue.length === 0) return;
|
|
108
|
+
const exposures = exposureQueue;
|
|
109
|
+
const metrics = metricQueue;
|
|
110
|
+
exposureQueue = [];
|
|
111
|
+
metricQueue = [];
|
|
112
|
+
const promises = [];
|
|
113
|
+
for (let i = 0; i < exposures.length; i += batchSize) {
|
|
114
|
+
const chunk = exposures.slice(i, i + batchSize);
|
|
115
|
+
promises.push(sendBatch(batchEndpoint, "events", chunk));
|
|
116
|
+
}
|
|
117
|
+
for (let i = 0; i < metrics.length; i += batchSize) {
|
|
118
|
+
const chunk = metrics.slice(i, i + batchSize);
|
|
119
|
+
promises.push(sendBatch(metricEndpoint, "metricEvents", chunk));
|
|
120
|
+
}
|
|
121
|
+
await Promise.all(promises);
|
|
122
|
+
}
|
|
123
|
+
async function sendBatch(endpoint, key, payload) {
|
|
124
|
+
const body = JSON.stringify({ sdkKey, [key]: payload });
|
|
125
|
+
for (let attempt = 0; attempt <= retryAttempts; attempt++) {
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(endpoint, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "Content-Type": "application/json" },
|
|
130
|
+
body,
|
|
131
|
+
signal: AbortSignal.timeout(1e4)
|
|
132
|
+
});
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(`HTTP ${response.status}`);
|
|
135
|
+
}
|
|
136
|
+
logger.debug(`Flushed ${payload.length} events to ${endpoint}`);
|
|
137
|
+
return;
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if (attempt < retryAttempts) {
|
|
140
|
+
const delay = 1e3 * Math.pow(2, attempt);
|
|
141
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
142
|
+
} else {
|
|
143
|
+
logger.error(
|
|
144
|
+
`Failed to flush ${payload.length} events after ${retryAttempts} retries`,
|
|
145
|
+
{ endpoint, error: String(e) }
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function destroy() {
|
|
152
|
+
destroyed = true;
|
|
153
|
+
if (flushTimer) {
|
|
154
|
+
clearInterval(flushTimer);
|
|
155
|
+
flushTimer = null;
|
|
156
|
+
}
|
|
157
|
+
await flush();
|
|
158
|
+
}
|
|
159
|
+
return { queue, queueMetric, flush, destroy };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/version.ts
|
|
163
|
+
var SDK_VERSION = "0.1.0";
|
|
164
|
+
|
|
165
|
+
// src/AvsBServer.ts
|
|
166
|
+
var DEFAULT_EVENTS = {
|
|
167
|
+
flushInterval: 2e3,
|
|
168
|
+
batchSize: 10,
|
|
169
|
+
maxQueueSize: 1e4,
|
|
170
|
+
retryAttempts: 3
|
|
171
|
+
};
|
|
172
|
+
var AvsBServer = class {
|
|
173
|
+
sdkKey;
|
|
174
|
+
pollingInterval;
|
|
175
|
+
cdnHost;
|
|
176
|
+
initTimeout;
|
|
177
|
+
logger;
|
|
178
|
+
stickyBucketService;
|
|
179
|
+
eventOptions;
|
|
180
|
+
datafile;
|
|
181
|
+
ready;
|
|
182
|
+
destroyed;
|
|
183
|
+
tracker;
|
|
184
|
+
pollingId;
|
|
185
|
+
overrides = /* @__PURE__ */ new Map();
|
|
186
|
+
listeners = /* @__PURE__ */ new Map();
|
|
187
|
+
readyPromise = null;
|
|
188
|
+
constructor(options) {
|
|
189
|
+
this.sdkKey = options.sdkKey;
|
|
190
|
+
this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
|
|
191
|
+
this.cdnHost = options.cdnHost ?? "https://cdn.avsb.app";
|
|
192
|
+
this.initTimeout = options.initTimeout ?? 1e4;
|
|
193
|
+
this.logger = options.logger ?? noopLogger;
|
|
194
|
+
this.stickyBucketService = options.stickyBucketService ?? null;
|
|
195
|
+
this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
|
|
196
|
+
this.datafile = options.bootstrap ?? null;
|
|
197
|
+
this.ready = !!options.bootstrap;
|
|
198
|
+
this.destroyed = false;
|
|
199
|
+
this.tracker = null;
|
|
200
|
+
this.pollingId = null;
|
|
201
|
+
}
|
|
202
|
+
onReady() {
|
|
203
|
+
if (this.readyPromise) return this.readyPromise;
|
|
204
|
+
this.readyPromise = this.initializeAndReady();
|
|
205
|
+
return this.readyPromise;
|
|
206
|
+
}
|
|
207
|
+
async initializeAndReady() {
|
|
208
|
+
if (this.ready && this.datafile) {
|
|
209
|
+
this.initTracker();
|
|
210
|
+
this.startPolling();
|
|
211
|
+
this.sendHeartbeat();
|
|
212
|
+
this.emit("ready");
|
|
213
|
+
return { success: true, source: "bootstrap" };
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const result = await Promise.race([
|
|
217
|
+
this.fetchDatafile().then((df) => ({ df, timedOut: false })),
|
|
218
|
+
new Promise(
|
|
219
|
+
(resolve) => setTimeout(() => resolve({ df: null, timedOut: true }), this.initTimeout)
|
|
220
|
+
)
|
|
221
|
+
]);
|
|
222
|
+
if (result.timedOut) {
|
|
223
|
+
this.startPolling();
|
|
224
|
+
const error2 = new Error(`SDK initialization timed out after ${this.initTimeout}ms`);
|
|
225
|
+
this.emit("error", error2);
|
|
226
|
+
return { success: false, source: "timeout", error: error2 };
|
|
227
|
+
}
|
|
228
|
+
if (result.df) {
|
|
229
|
+
this.datafile = result.df;
|
|
230
|
+
this.initTracker();
|
|
231
|
+
this.ready = true;
|
|
232
|
+
this.startPolling();
|
|
233
|
+
this.sendHeartbeat();
|
|
234
|
+
this.emit("ready");
|
|
235
|
+
return { success: true, source: "network" };
|
|
236
|
+
}
|
|
237
|
+
this.startPolling();
|
|
238
|
+
const error = new Error("Failed to fetch datafile");
|
|
239
|
+
this.emit("error", error);
|
|
240
|
+
return { success: false, source: "error", error };
|
|
241
|
+
} catch (e) {
|
|
242
|
+
this.startPolling();
|
|
243
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
244
|
+
this.emit("error", error);
|
|
245
|
+
return { success: false, source: "error", error };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async close() {
|
|
249
|
+
this.destroyed = true;
|
|
250
|
+
if (this.pollingId) {
|
|
251
|
+
clearInterval(this.pollingId);
|
|
252
|
+
this.pollingId = null;
|
|
253
|
+
}
|
|
254
|
+
if (this.tracker) {
|
|
255
|
+
await this.tracker.destroy();
|
|
256
|
+
this.tracker = null;
|
|
257
|
+
}
|
|
258
|
+
this.datafile = null;
|
|
259
|
+
this.ready = false;
|
|
260
|
+
}
|
|
261
|
+
async flush() {
|
|
262
|
+
if (this.tracker) await this.tracker.flush();
|
|
263
|
+
}
|
|
264
|
+
async refreshDatafile() {
|
|
265
|
+
const df = await this.fetchDatafile();
|
|
266
|
+
if (!df) {
|
|
267
|
+
this.logger.warn("Datafile refresh failed, keeping current datafile");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const changed = !this.datafile || this.datafile.publishedAt !== df.publishedAt;
|
|
271
|
+
this.datafile = df;
|
|
272
|
+
if (!this.ready) {
|
|
273
|
+
this.initTracker();
|
|
274
|
+
this.ready = true;
|
|
275
|
+
this.emit("ready");
|
|
276
|
+
} else if (changed) {
|
|
277
|
+
this.emit("configUpdate", { publishedAt: df.publishedAt });
|
|
278
|
+
this.logger.info("Datafile updated", { publishedAt: df.publishedAt });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
getFlag(flagKey, userContext, defaultValue) {
|
|
282
|
+
if (!this.ready || !this.datafile) {
|
|
283
|
+
this.logger.debug(`SDK not ready, returning default for ${flagKey}`);
|
|
284
|
+
return defaultValue;
|
|
285
|
+
}
|
|
286
|
+
const result = this.evaluate(flagKey, userContext, defaultValue);
|
|
287
|
+
this.emitEvaluation(flagKey, result.value, userContext.userId, result.source);
|
|
288
|
+
return result.value ?? defaultValue;
|
|
289
|
+
}
|
|
290
|
+
getFlagDetails(flagKey, userContext) {
|
|
291
|
+
if (!this.ready || !this.datafile) {
|
|
292
|
+
return {
|
|
293
|
+
value: void 0,
|
|
294
|
+
variationKey: null,
|
|
295
|
+
source: "not_found",
|
|
296
|
+
ruleId: null,
|
|
297
|
+
ruleType: null,
|
|
298
|
+
flagKey,
|
|
299
|
+
reasons: ["SDK not ready"]
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const r = this.evaluate(flagKey, userContext, void 0);
|
|
303
|
+
return {
|
|
304
|
+
value: r.value,
|
|
305
|
+
variationKey: r.variationKey,
|
|
306
|
+
source: r.source,
|
|
307
|
+
ruleId: r.ruleId,
|
|
308
|
+
ruleType: r.ruleType,
|
|
309
|
+
flagKey,
|
|
310
|
+
reasons: r.reasons
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
getAllFlags(userContext) {
|
|
314
|
+
if (!this.ready || !this.datafile) return {};
|
|
315
|
+
const out = {};
|
|
316
|
+
for (const flag of this.datafile.flags) out[flag.key] = this.getFlag(flag.key, userContext);
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
track(eventKey, context) {
|
|
320
|
+
if (!this.ready || !this.tracker) {
|
|
321
|
+
this.logger.warn(`SDK not ready, dropping track event "${eventKey}"`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.tracker.queueMetric({
|
|
325
|
+
visitorId: context.userId,
|
|
326
|
+
eventName: eventKey,
|
|
327
|
+
revenue: context.revenue ?? null,
|
|
328
|
+
timestamp: Date.now(),
|
|
329
|
+
pageUrl: ""
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
logExposure(flagKey, userContext) {
|
|
333
|
+
if (!this.ready || !this.datafile || !this.tracker) return;
|
|
334
|
+
const ctx = { attributes: { userId: userContext.userId } };
|
|
335
|
+
const result = (0, import_js.evaluateFlag)(flagKey, void 0, ctx, this.datafile);
|
|
336
|
+
if (result.source !== "rule" || !result.ruleId) return;
|
|
337
|
+
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
338
|
+
if (!flag) return;
|
|
339
|
+
const rule = flag.rules.find((r) => r.id === result.ruleId);
|
|
340
|
+
if (!rule || rule.type !== "ab_test") return;
|
|
341
|
+
const rv = rule.variations.find((v) => {
|
|
342
|
+
const fv = flag.variations.find((x) => x.id === v.variationId);
|
|
343
|
+
return fv?.key === result.variationKey;
|
|
344
|
+
});
|
|
345
|
+
if (!rv) return;
|
|
346
|
+
this.tracker.queue({
|
|
347
|
+
visitorId: userContext.userId,
|
|
348
|
+
experimentId: result.ruleId,
|
|
349
|
+
variationId: rv.variationId,
|
|
350
|
+
eventName: "flag_exposure",
|
|
351
|
+
revenue: null,
|
|
352
|
+
timestamp: Date.now(),
|
|
353
|
+
pageUrl: "",
|
|
354
|
+
properties: { flagKey, flagId: flag.id }
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
setOverride(flagKey, variationKey, userId) {
|
|
358
|
+
if (!this.overrides.has(flagKey)) this.overrides.set(flagKey, /* @__PURE__ */ new Map());
|
|
359
|
+
this.overrides.get(flagKey).set(userId ?? "*", variationKey);
|
|
360
|
+
}
|
|
361
|
+
clearOverride(flagKey, userId) {
|
|
362
|
+
const m = this.overrides.get(flagKey);
|
|
363
|
+
if (!m) return;
|
|
364
|
+
m.delete(userId ?? "*");
|
|
365
|
+
if (m.size === 0) this.overrides.delete(flagKey);
|
|
366
|
+
}
|
|
367
|
+
clearAllOverrides() {
|
|
368
|
+
this.overrides.clear();
|
|
369
|
+
}
|
|
370
|
+
createDecisionLog() {
|
|
371
|
+
return createDecisionLog();
|
|
372
|
+
}
|
|
373
|
+
on(event, cb) {
|
|
374
|
+
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
375
|
+
this.listeners.get(event).add(cb);
|
|
376
|
+
}
|
|
377
|
+
off(event, cb) {
|
|
378
|
+
this.listeners.get(event)?.delete(cb);
|
|
379
|
+
}
|
|
380
|
+
evaluate(flagKey, uc, defaultValue) {
|
|
381
|
+
const startTime = Date.now();
|
|
382
|
+
const reasons = [];
|
|
383
|
+
const rtOver = this.resolveRuntimeOverride(flagKey, uc.userId);
|
|
384
|
+
if (rtOver !== void 0) {
|
|
385
|
+
reasons.push(`matched runtime override for flagKey="${flagKey}"`);
|
|
386
|
+
this.recordDecisionLog(uc, {
|
|
387
|
+
flagKey,
|
|
388
|
+
value: rtOver.value,
|
|
389
|
+
variationKey: rtOver.key,
|
|
390
|
+
source: "runtime_override",
|
|
391
|
+
ruleId: null,
|
|
392
|
+
ruleType: null,
|
|
393
|
+
reasons,
|
|
394
|
+
startTime
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
value: rtOver.value,
|
|
398
|
+
variationKey: rtOver.key,
|
|
399
|
+
source: "runtime_override",
|
|
400
|
+
ruleId: null,
|
|
401
|
+
ruleType: null,
|
|
402
|
+
reasons
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (this.stickyBucketService) {
|
|
406
|
+
const sticky = this.resolveStickyBucket(flagKey, uc.userId);
|
|
407
|
+
if (sticky !== void 0) {
|
|
408
|
+
reasons.push(`matched sticky bucket assignment for userId="${uc.userId}"`);
|
|
409
|
+
if (!uc.suppressExposure) this.fireExposureForVariation(flagKey, sticky, uc);
|
|
410
|
+
this.recordDecisionLog(uc, {
|
|
411
|
+
flagKey,
|
|
412
|
+
value: sticky.value,
|
|
413
|
+
variationKey: sticky.key,
|
|
414
|
+
source: "sticky",
|
|
415
|
+
ruleId: null,
|
|
416
|
+
ruleType: null,
|
|
417
|
+
reasons,
|
|
418
|
+
startTime
|
|
419
|
+
});
|
|
420
|
+
return {
|
|
421
|
+
value: sticky.value,
|
|
422
|
+
variationKey: sticky.key,
|
|
423
|
+
source: "sticky",
|
|
424
|
+
ruleId: null,
|
|
425
|
+
ruleType: null,
|
|
426
|
+
reasons
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const context = { attributes: { userId: uc.userId, ...uc.attributes } };
|
|
431
|
+
const shouldFire = !uc.suppressExposure && !uc.decisionLog?.entries.some(
|
|
432
|
+
(e) => e.flagKey === flagKey && e.source !== "not_found" && e.source !== "disabled"
|
|
433
|
+
);
|
|
434
|
+
const onExposure = shouldFire ? (event) => {
|
|
435
|
+
if (uc.privateAttributes?.length && event.properties) {
|
|
436
|
+
const cleaned = { ...event.properties };
|
|
437
|
+
for (const k of uc.privateAttributes) delete cleaned[k];
|
|
438
|
+
event = { ...event, properties: cleaned };
|
|
439
|
+
}
|
|
440
|
+
this.tracker?.queue(event);
|
|
441
|
+
} : void 0;
|
|
442
|
+
const r = (0, import_js.evaluateFlag)(flagKey, defaultValue, context, this.datafile, onExposure, reasons);
|
|
443
|
+
if (this.stickyBucketService && r.variationKey) {
|
|
444
|
+
const v = this.findVariation(flagKey, r.variationKey);
|
|
445
|
+
if (v) this.stickyBucketService.save(uc.userId, flagKey, v.id);
|
|
446
|
+
}
|
|
447
|
+
this.recordDecisionLog(uc, {
|
|
448
|
+
flagKey,
|
|
449
|
+
value: r.value,
|
|
450
|
+
variationKey: r.variationKey,
|
|
451
|
+
source: r.source,
|
|
452
|
+
ruleId: r.ruleId,
|
|
453
|
+
ruleType: r.ruleType,
|
|
454
|
+
reasons,
|
|
455
|
+
startTime
|
|
456
|
+
});
|
|
457
|
+
return {
|
|
458
|
+
value: r.value,
|
|
459
|
+
variationKey: r.variationKey,
|
|
460
|
+
source: r.source,
|
|
461
|
+
ruleId: r.ruleId,
|
|
462
|
+
ruleType: r.ruleType,
|
|
463
|
+
reasons
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
emit(event, ...args) {
|
|
467
|
+
const cbs = this.listeners.get(event);
|
|
468
|
+
if (!cbs) return;
|
|
469
|
+
for (const cb of cbs) {
|
|
470
|
+
try {
|
|
471
|
+
cb(...args);
|
|
472
|
+
} catch (e) {
|
|
473
|
+
this.logger.error("Listener error", { event, error: String(e) });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
initTracker() {
|
|
478
|
+
if (!this.datafile || this.tracker) return;
|
|
479
|
+
this.tracker = createServerTracker({
|
|
480
|
+
sdkKey: this.sdkKey,
|
|
481
|
+
collectEndpoint: this.datafile.collectEndpoint,
|
|
482
|
+
flushInterval: this.eventOptions.flushInterval,
|
|
483
|
+
batchSize: this.eventOptions.batchSize,
|
|
484
|
+
maxQueueSize: this.eventOptions.maxQueueSize,
|
|
485
|
+
retryAttempts: this.eventOptions.retryAttempts,
|
|
486
|
+
logger: this.logger
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
async fetchDatafile() {
|
|
490
|
+
const url = `${this.cdnHost}/${this.sdkKey}/datafile.json`;
|
|
491
|
+
try {
|
|
492
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
493
|
+
if (!res.ok) {
|
|
494
|
+
this.logger.warn("Datafile fetch failed", { status: res.status, url });
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
return await res.json();
|
|
498
|
+
} catch (e) {
|
|
499
|
+
this.logger.warn("Datafile fetch error", { error: String(e), url });
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
startPolling() {
|
|
504
|
+
if (this.pollingId || this.destroyed) return;
|
|
505
|
+
this.pollingId = setInterval(async () => {
|
|
506
|
+
const df = await this.fetchDatafile();
|
|
507
|
+
if (!df) return;
|
|
508
|
+
const changed = !this.datafile || this.datafile.publishedAt !== df.publishedAt;
|
|
509
|
+
if (!this.ready) {
|
|
510
|
+
this.datafile = df;
|
|
511
|
+
this.initTracker();
|
|
512
|
+
this.ready = true;
|
|
513
|
+
this.emit("ready");
|
|
514
|
+
} else if (changed) {
|
|
515
|
+
this.datafile = df;
|
|
516
|
+
this.emit("configUpdate", { publishedAt: df.publishedAt });
|
|
517
|
+
this.logger.info("Datafile updated", { publishedAt: df.publishedAt });
|
|
518
|
+
}
|
|
519
|
+
}, this.pollingInterval);
|
|
520
|
+
}
|
|
521
|
+
sendHeartbeat() {
|
|
522
|
+
if (!this.datafile?.heartbeatEndpoint) return;
|
|
523
|
+
fetch(this.datafile.heartbeatEndpoint, {
|
|
524
|
+
method: "POST",
|
|
525
|
+
headers: { "Content-Type": "application/json" },
|
|
526
|
+
body: JSON.stringify({ sdkKey: this.sdkKey, sdkType: "@avsbhq/node", sdkVersion: SDK_VERSION })
|
|
527
|
+
}).catch(() => {
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
resolveRuntimeOverride(flagKey, userId) {
|
|
531
|
+
const m = this.overrides.get(flagKey);
|
|
532
|
+
if (!m) return void 0;
|
|
533
|
+
const vk = m.get(userId) ?? m.get("*");
|
|
534
|
+
if (!vk) return void 0;
|
|
535
|
+
return this.findVariation(flagKey, vk) ?? void 0;
|
|
536
|
+
}
|
|
537
|
+
resolveStickyBucket(flagKey, userId) {
|
|
538
|
+
if (!this.stickyBucketService || !this.datafile) return void 0;
|
|
539
|
+
const vid = this.stickyBucketService.lookup(userId, flagKey);
|
|
540
|
+
if (!vid) return void 0;
|
|
541
|
+
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
542
|
+
if (!flag) return void 0;
|
|
543
|
+
const v = flag.variations.find((x) => x.id === vid);
|
|
544
|
+
if (!v) return void 0;
|
|
545
|
+
return { value: v.value, key: v.key, id: v.id };
|
|
546
|
+
}
|
|
547
|
+
findVariation(flagKey, variationKey) {
|
|
548
|
+
if (!this.datafile) return null;
|
|
549
|
+
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
550
|
+
if (!flag) return null;
|
|
551
|
+
const v = flag.variations.find((x) => x.key === variationKey);
|
|
552
|
+
if (!v) return null;
|
|
553
|
+
return { value: v.value, key: v.key, id: v.id };
|
|
554
|
+
}
|
|
555
|
+
fireExposureForVariation(flagKey, vr, uc) {
|
|
556
|
+
if (!this.tracker || !this.datafile) return;
|
|
557
|
+
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
558
|
+
if (!flag) return;
|
|
559
|
+
for (const rule of flag.rules) {
|
|
560
|
+
if (rule.type !== "ab_test") continue;
|
|
561
|
+
if (rule.variations.some((rv) => rv.variationId === vr.id)) {
|
|
562
|
+
this.tracker.queue({
|
|
563
|
+
visitorId: uc.userId,
|
|
564
|
+
experimentId: rule.id,
|
|
565
|
+
variationId: vr.id,
|
|
566
|
+
eventName: "flag_exposure",
|
|
567
|
+
revenue: null,
|
|
568
|
+
timestamp: Date.now(),
|
|
569
|
+
pageUrl: "",
|
|
570
|
+
properties: { flagKey, flagId: flag.id }
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
recordDecisionLog(uc, d) {
|
|
577
|
+
if (!uc.decisionLog) return;
|
|
578
|
+
uc.decisionLog.entries.push({
|
|
579
|
+
flagKey: d.flagKey,
|
|
580
|
+
value: d.value,
|
|
581
|
+
variationKey: d.variationKey,
|
|
582
|
+
source: d.source,
|
|
583
|
+
ruleId: d.ruleId,
|
|
584
|
+
ruleType: d.ruleType,
|
|
585
|
+
reasons: [...d.reasons],
|
|
586
|
+
evaluatedAt: d.startTime,
|
|
587
|
+
durationMs: Date.now() - d.startTime
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
emitEvaluation(flagKey, value, userId, source) {
|
|
591
|
+
this.emit("evaluation", { flagKey, value, userId, source });
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// src/middleware.ts
|
|
596
|
+
function createNoopRequestAvsB() {
|
|
597
|
+
return {
|
|
598
|
+
getFlag(_flagKey, defaultValue) {
|
|
599
|
+
return defaultValue;
|
|
600
|
+
},
|
|
601
|
+
getFlagDetails(flagKey) {
|
|
602
|
+
return {
|
|
603
|
+
value: void 0,
|
|
604
|
+
variationKey: null,
|
|
605
|
+
source: "not_found",
|
|
606
|
+
ruleId: null,
|
|
607
|
+
ruleType: null,
|
|
608
|
+
flagKey,
|
|
609
|
+
reasons: ["no userId available"]
|
|
610
|
+
};
|
|
611
|
+
},
|
|
612
|
+
getAllFlags() {
|
|
613
|
+
return {};
|
|
614
|
+
},
|
|
615
|
+
track() {
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function expressMiddleware(client, config) {
|
|
620
|
+
return (req, res, next) => {
|
|
621
|
+
const userId = config.userIdFrom(req);
|
|
622
|
+
if (!userId) {
|
|
623
|
+
;
|
|
624
|
+
req.avsb = createNoopRequestAvsB();
|
|
625
|
+
return next();
|
|
626
|
+
}
|
|
627
|
+
const attributes = config.attributesFrom?.(req) ?? {};
|
|
628
|
+
const baseContext = { userId, attributes };
|
|
629
|
+
req.avsb = {
|
|
630
|
+
getFlag(flagKey, defaultValue) {
|
|
631
|
+
return client.getFlag(flagKey, baseContext, defaultValue);
|
|
632
|
+
},
|
|
633
|
+
getFlagDetails(flagKey) {
|
|
634
|
+
return client.getFlagDetails(flagKey, baseContext);
|
|
635
|
+
},
|
|
636
|
+
getAllFlags() {
|
|
637
|
+
return client.getAllFlags(baseContext);
|
|
638
|
+
},
|
|
639
|
+
track(eventKey, properties) {
|
|
640
|
+
client.track(eventKey, { userId, revenue: properties?.revenue, properties });
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
next();
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
function fastifyPlugin(instance, opts, done) {
|
|
647
|
+
const fastify = instance;
|
|
648
|
+
fastify.decorateRequest("avsb", null);
|
|
649
|
+
fastify.addHook("onRequest", (...args) => {
|
|
650
|
+
const req = args[0];
|
|
651
|
+
const hookDone = args[2];
|
|
652
|
+
const userId = opts.userIdFrom(req);
|
|
653
|
+
if (!userId) {
|
|
654
|
+
;
|
|
655
|
+
req.avsb = createNoopRequestAvsB();
|
|
656
|
+
return hookDone();
|
|
657
|
+
}
|
|
658
|
+
const attributes = opts.attributesFrom?.(req) ?? {};
|
|
659
|
+
const baseContext = { userId, attributes };
|
|
660
|
+
req.avsb = {
|
|
661
|
+
getFlag(flagKey, defaultValue) {
|
|
662
|
+
return opts.client.getFlag(flagKey, baseContext, defaultValue);
|
|
663
|
+
},
|
|
664
|
+
getFlagDetails(flagKey) {
|
|
665
|
+
return opts.client.getFlagDetails(flagKey, baseContext);
|
|
666
|
+
},
|
|
667
|
+
getAllFlags() {
|
|
668
|
+
return opts.client.getAllFlags(baseContext);
|
|
669
|
+
},
|
|
670
|
+
track(eventKey, properties) {
|
|
671
|
+
opts.client.track(eventKey, { userId, revenue: properties?.revenue, properties });
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
hookDone();
|
|
675
|
+
});
|
|
676
|
+
done();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/stickyBucket.ts
|
|
680
|
+
var InMemoryStickyBucketService = class {
|
|
681
|
+
cache = /* @__PURE__ */ new Map();
|
|
682
|
+
key(userId, flagKey) {
|
|
683
|
+
return `${userId}:${flagKey}`;
|
|
684
|
+
}
|
|
685
|
+
lookup(userId, flagKey) {
|
|
686
|
+
return this.cache.get(this.key(userId, flagKey)) ?? null;
|
|
687
|
+
}
|
|
688
|
+
save(userId, flagKey, variationId) {
|
|
689
|
+
this.cache.set(this.key(userId, flagKey), variationId);
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
var RedisStickyBucketService = class {
|
|
693
|
+
redis;
|
|
694
|
+
prefix;
|
|
695
|
+
ttl;
|
|
696
|
+
maxCacheSize;
|
|
697
|
+
logger;
|
|
698
|
+
cache = /* @__PURE__ */ new Map();
|
|
699
|
+
constructor(options) {
|
|
700
|
+
this.redis = options.redis;
|
|
701
|
+
this.prefix = options.prefix ?? "avsb:sticky:";
|
|
702
|
+
this.ttl = options.ttl ?? 2592e3;
|
|
703
|
+
this.maxCacheSize = options.cacheSize ?? 1e4;
|
|
704
|
+
this.logger = options.logger ?? noopLogger;
|
|
705
|
+
}
|
|
706
|
+
redisKey(userId, flagKey) {
|
|
707
|
+
return `${this.prefix}${userId}:${flagKey}`;
|
|
708
|
+
}
|
|
709
|
+
cacheKey(userId, flagKey) {
|
|
710
|
+
return `${userId}:${flagKey}`;
|
|
711
|
+
}
|
|
712
|
+
lookup(userId, flagKey) {
|
|
713
|
+
const ck = this.cacheKey(userId, flagKey);
|
|
714
|
+
const cached = this.cache.get(ck);
|
|
715
|
+
if (cached !== void 0) return cached;
|
|
716
|
+
this.backgroundFetch(userId, flagKey);
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
save(userId, flagKey, variationId) {
|
|
720
|
+
const ck = this.cacheKey(userId, flagKey);
|
|
721
|
+
this.cache.set(ck, variationId);
|
|
722
|
+
this.evictIfNeeded();
|
|
723
|
+
const rk = this.redisKey(userId, flagKey);
|
|
724
|
+
this.redis.set(rk, variationId, { EX: this.ttl }).catch((e) => {
|
|
725
|
+
this.logger.error("Redis sticky bucket save failed", {
|
|
726
|
+
key: rk,
|
|
727
|
+
error: String(e)
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
backgroundFetch(userId, flagKey) {
|
|
732
|
+
const rk = this.redisKey(userId, flagKey);
|
|
733
|
+
const ck = this.cacheKey(userId, flagKey);
|
|
734
|
+
this.redis.get(rk).then((value) => {
|
|
735
|
+
if (value !== null) {
|
|
736
|
+
this.cache.set(ck, value);
|
|
737
|
+
this.evictIfNeeded();
|
|
738
|
+
}
|
|
739
|
+
}).catch((e) => {
|
|
740
|
+
this.logger.error("Redis sticky bucket fetch failed", {
|
|
741
|
+
key: rk,
|
|
742
|
+
error: String(e)
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
evictIfNeeded() {
|
|
747
|
+
if (this.cache.size <= this.maxCacheSize) return;
|
|
748
|
+
const firstKey = this.cache.keys().next().value;
|
|
749
|
+
if (firstKey !== void 0) {
|
|
750
|
+
this.cache.delete(firstKey);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// src/streaming.ts
|
|
756
|
+
function createStreamingClient(options) {
|
|
757
|
+
const {
|
|
758
|
+
endpoint,
|
|
759
|
+
sdkKey,
|
|
760
|
+
onDatafileUpdated,
|
|
761
|
+
logger,
|
|
762
|
+
reconnectDelay = 5e3
|
|
763
|
+
} = options;
|
|
764
|
+
let connected = false;
|
|
765
|
+
let abortController = null;
|
|
766
|
+
let reconnectTimer = null;
|
|
767
|
+
function connect() {
|
|
768
|
+
if (connected) return;
|
|
769
|
+
startConnection();
|
|
770
|
+
}
|
|
771
|
+
function disconnect() {
|
|
772
|
+
connected = false;
|
|
773
|
+
if (abortController) {
|
|
774
|
+
abortController.abort();
|
|
775
|
+
abortController = null;
|
|
776
|
+
}
|
|
777
|
+
if (reconnectTimer) {
|
|
778
|
+
clearTimeout(reconnectTimer);
|
|
779
|
+
reconnectTimer = null;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
function isConnected() {
|
|
783
|
+
return connected;
|
|
784
|
+
}
|
|
785
|
+
async function startConnection() {
|
|
786
|
+
abortController = new AbortController();
|
|
787
|
+
const url = `${endpoint}?sdkKey=${encodeURIComponent(sdkKey)}`;
|
|
788
|
+
try {
|
|
789
|
+
const response = await fetch(url, {
|
|
790
|
+
headers: { Accept: "text/event-stream" },
|
|
791
|
+
signal: abortController.signal
|
|
792
|
+
});
|
|
793
|
+
if (!response.ok) {
|
|
794
|
+
logger.warn("SSE connection failed", { status: response.status, url });
|
|
795
|
+
scheduleReconnect();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (!response.body) {
|
|
799
|
+
logger.warn("SSE response has no body");
|
|
800
|
+
scheduleReconnect();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
connected = true;
|
|
804
|
+
logger.info(`SSE connected to ${endpoint}`);
|
|
805
|
+
const reader = response.body.getReader();
|
|
806
|
+
const decoder = new TextDecoder();
|
|
807
|
+
let buffer = "";
|
|
808
|
+
try {
|
|
809
|
+
while (true) {
|
|
810
|
+
const { done, value } = await reader.read();
|
|
811
|
+
if (done) break;
|
|
812
|
+
buffer += decoder.decode(value, { stream: true });
|
|
813
|
+
const lines = buffer.split("\n");
|
|
814
|
+
buffer = lines.pop() ?? "";
|
|
815
|
+
for (const line of lines) {
|
|
816
|
+
if (line.startsWith("data: ")) {
|
|
817
|
+
const data = line.slice(6).trim();
|
|
818
|
+
if (!data) continue;
|
|
819
|
+
try {
|
|
820
|
+
const parsed = JSON.parse(data);
|
|
821
|
+
if (parsed.type === "datafile_updated") {
|
|
822
|
+
onDatafileUpdated();
|
|
823
|
+
}
|
|
824
|
+
} catch {
|
|
825
|
+
logger.warn("Failed to parse SSE data", { data });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
} catch (e) {
|
|
831
|
+
if (abortController?.signal.aborted) return;
|
|
832
|
+
logger.warn("SSE stream read error", { error: String(e) });
|
|
833
|
+
}
|
|
834
|
+
connected = false;
|
|
835
|
+
logger.warn(`SSE disconnected, reconnecting in ${reconnectDelay}ms`);
|
|
836
|
+
scheduleReconnect();
|
|
837
|
+
} catch (e) {
|
|
838
|
+
if (abortController?.signal.aborted) return;
|
|
839
|
+
logger.warn("SSE connection error", { error: String(e) });
|
|
840
|
+
connected = false;
|
|
841
|
+
scheduleReconnect();
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function scheduleReconnect() {
|
|
845
|
+
if (reconnectTimer) return;
|
|
846
|
+
reconnectTimer = setTimeout(() => {
|
|
847
|
+
reconnectTimer = null;
|
|
848
|
+
startConnection();
|
|
849
|
+
}, reconnectDelay);
|
|
850
|
+
}
|
|
851
|
+
return { connect, disconnect, isConnected };
|
|
852
|
+
}
|
|
853
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
854
|
+
0 && (module.exports = {
|
|
855
|
+
AvsBServer,
|
|
856
|
+
InMemoryStickyBucketService,
|
|
857
|
+
RedisStickyBucketService,
|
|
858
|
+
SDK_VERSION,
|
|
859
|
+
createDecisionLog,
|
|
860
|
+
createStreamingClient,
|
|
861
|
+
expressMiddleware,
|
|
862
|
+
fastifyPlugin,
|
|
863
|
+
noopLogger
|
|
864
|
+
});
|