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