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