@avsbhq/node 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/index.cjs +67 -178
- package/dist/index.d.cts +29 -15
- package/dist/index.d.ts +29 -15
- package/dist/index.js +68 -176
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -49,13 +49,13 @@ Instantiate `AvsbServer` once at app boot. The server holds the datafile in memo
|
|
|
49
49
|
|
|
50
50
|
## 3. SDK keys
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Get the SDK key for the environment you want to target from your A vs B project's Environments page. The key format is `sdk_<environment>_<id>` (e.g. `sdk_production_clp1a2b3c4d5e6`).
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
AVSB_SDK_KEY=
|
|
55
|
+
AVSB_SDK_KEY=sdk_production_...
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
When used server-side, the SDK key only ever needs to leave the server to hit the datafile CDN — it should never end up in client-rendered HTML, browser bundles, or browser-accessible config. Store it in environment variables only.
|
|
59
59
|
|
|
60
60
|
Server SDK keys cannot be rotated without a process restart; the new key must be set in the environment before the server boots (or trigger a rolling restart in your orchestrator).
|
|
61
61
|
|
package/dist/index.cjs
CHANGED
|
@@ -29,7 +29,7 @@ __export(index_exports, {
|
|
|
29
29
|
consoleTransport: () => import_core3.consoleTransport,
|
|
30
30
|
createDecisionLog: () => createDecisionLog,
|
|
31
31
|
createLogger: () => import_core3.createLogger,
|
|
32
|
-
createStreamingClient: () => createStreamingClient,
|
|
32
|
+
createStreamingClient: () => import_streaming.createStreamingClient,
|
|
33
33
|
expressMiddleware: () => expressMiddleware,
|
|
34
34
|
fastifyPlugin: () => fastifyPlugin,
|
|
35
35
|
noopLogger: () => import_core3.noopLogger
|
|
@@ -46,6 +46,9 @@ function createDecisionLog() {
|
|
|
46
46
|
return { entries };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// src/serverTracker.ts
|
|
50
|
+
var import_node_crypto = require("crypto");
|
|
51
|
+
|
|
49
52
|
// src/reconnectBackoff.ts
|
|
50
53
|
var BASE_DELAY_MS = 1e3;
|
|
51
54
|
var MAX_DELAY_MS = 6e4;
|
|
@@ -74,6 +77,12 @@ function parseRetryAfter(headerValue) {
|
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
// src/serverTracker.ts
|
|
80
|
+
function stampExposure(event) {
|
|
81
|
+
return event.eventId ? event : { ...event, eventId: (0, import_node_crypto.randomUUID)() };
|
|
82
|
+
}
|
|
83
|
+
function stampMetric(event) {
|
|
84
|
+
return event.eventId ? event : { ...event, eventId: (0, import_node_crypto.randomUUID)() };
|
|
85
|
+
}
|
|
77
86
|
function createServerTracker(options) {
|
|
78
87
|
const {
|
|
79
88
|
sdkKey,
|
|
@@ -86,6 +95,7 @@ function createServerTracker(options) {
|
|
|
86
95
|
} = options;
|
|
87
96
|
const batchEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/batch");
|
|
88
97
|
const metricEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/metric-batch");
|
|
98
|
+
const ordersEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/orders");
|
|
89
99
|
let exposureQueue = [];
|
|
90
100
|
let metricQueue = [];
|
|
91
101
|
let flushTimer = null;
|
|
@@ -97,7 +107,7 @@ function createServerTracker(options) {
|
|
|
97
107
|
}, flushInterval);
|
|
98
108
|
function queue(event) {
|
|
99
109
|
if (destroyed) return;
|
|
100
|
-
exposureQueue.push(event);
|
|
110
|
+
exposureQueue.push(stampExposure(event));
|
|
101
111
|
if (exposureQueue.length > maxQueueSize) {
|
|
102
112
|
const dropped = exposureQueue.length - maxQueueSize;
|
|
103
113
|
exposureQueue = exposureQueue.slice(-maxQueueSize);
|
|
@@ -111,7 +121,7 @@ function createServerTracker(options) {
|
|
|
111
121
|
}
|
|
112
122
|
function queueMetric(event) {
|
|
113
123
|
if (destroyed) return;
|
|
114
|
-
metricQueue.push(event);
|
|
124
|
+
metricQueue.push(stampMetric(event));
|
|
115
125
|
if (metricQueue.length > maxQueueSize) {
|
|
116
126
|
const dropped = metricQueue.length - maxQueueSize;
|
|
117
127
|
metricQueue = metricQueue.slice(-maxQueueSize);
|
|
@@ -173,6 +183,47 @@ function createServerTracker(options) {
|
|
|
173
183
|
}
|
|
174
184
|
}
|
|
175
185
|
}
|
|
186
|
+
async function trackPurchase(visitorId, order) {
|
|
187
|
+
if (destroyed) return;
|
|
188
|
+
const payload = {
|
|
189
|
+
...order,
|
|
190
|
+
eventId: (0, import_node_crypto.randomUUID)(),
|
|
191
|
+
visitorId,
|
|
192
|
+
timestamp: Date.now(),
|
|
193
|
+
pageUrl: ""
|
|
194
|
+
};
|
|
195
|
+
const body = JSON.stringify({ sdkKey, orders: [payload] });
|
|
196
|
+
let backoff = resetBackoff();
|
|
197
|
+
let pendingRetryAfterMs = void 0;
|
|
198
|
+
for (let attempt = 0; attempt <= retryAttempts; attempt++) {
|
|
199
|
+
try {
|
|
200
|
+
const response = await fetch(ordersEndpoint, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/json" },
|
|
203
|
+
body,
|
|
204
|
+
signal: AbortSignal.timeout(1e4)
|
|
205
|
+
});
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
const parsed = parseRetryAfter(response.headers.get("retry-after"));
|
|
208
|
+
if (parsed !== null) pendingRetryAfterMs = parsed;
|
|
209
|
+
throw new Error(`HTTP ${response.status}`);
|
|
210
|
+
}
|
|
211
|
+
logger.debug(`Purchase order ${order.orderId} sent for visitor ${visitorId}`);
|
|
212
|
+
return;
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (attempt < retryAttempts) {
|
|
215
|
+
backoff = nextBackoff(backoff, pendingRetryAfterMs);
|
|
216
|
+
pendingRetryAfterMs = void 0;
|
|
217
|
+
await new Promise((r) => setTimeout(r, backoff.delayMs));
|
|
218
|
+
} else {
|
|
219
|
+
logger.error(
|
|
220
|
+
`Failed to send purchase order ${order.orderId} after ${retryAttempts} retries`,
|
|
221
|
+
{ endpoint: ordersEndpoint, error: String(e) }
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
176
227
|
async function destroy() {
|
|
177
228
|
destroyed = true;
|
|
178
229
|
if (flushTimer) {
|
|
@@ -181,183 +232,11 @@ function createServerTracker(options) {
|
|
|
181
232
|
}
|
|
182
233
|
await flush();
|
|
183
234
|
}
|
|
184
|
-
return { queue, queueMetric, flush, destroy };
|
|
235
|
+
return { queue, queueMetric, trackPurchase, flush, destroy };
|
|
185
236
|
}
|
|
186
237
|
|
|
187
238
|
// src/streaming.ts
|
|
188
|
-
|
|
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
|
-
}
|
|
239
|
+
var import_streaming = require("@avsbhq/utils/streaming");
|
|
361
240
|
|
|
362
241
|
// src/version.ts
|
|
363
242
|
var SDK_VERSION = "0.3.0";
|
|
@@ -720,6 +599,16 @@ var AvsbServer = class {
|
|
|
720
599
|
return out;
|
|
721
600
|
}
|
|
722
601
|
// ---------------------------------------------------------------------------
|
|
602
|
+
// Purchase tracking — fires immediately to /v1/collect/orders
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
async trackPurchase(visitorId, order) {
|
|
605
|
+
if (!this.tracker) {
|
|
606
|
+
this.logger.warn("trackPurchase called before tracker is ready; order dropped");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
return this.tracker.trackPurchase(visitorId, order);
|
|
610
|
+
}
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
723
612
|
// §5.6 Unified track — context required
|
|
724
613
|
// ---------------------------------------------------------------------------
|
|
725
614
|
track(eventKey, payload) {
|
|
@@ -1101,7 +990,7 @@ var AvsbServer = class {
|
|
|
1101
990
|
}
|
|
1102
991
|
startStreamingIfEnabled() {
|
|
1103
992
|
if (!this.streamingEnabled || this.streamingClient || this.destroyed) return;
|
|
1104
|
-
this.streamingClient = createStreamingClient({
|
|
993
|
+
this.streamingClient = (0, import_streaming.createStreamingClient)({
|
|
1105
994
|
endpoint: this.streamingEndpoint,
|
|
1106
995
|
sdkKey: this.sdkKey,
|
|
1107
996
|
onDatafileUpdated: () => {
|
package/dist/index.d.cts
CHANGED
|
@@ -2,6 +2,7 @@ import { FlagDatafile, StickyBucketService, Logger, EvaluationSource, StickyAssi
|
|
|
2
2
|
export { DecideOption, DecisionLog, DecisionLogEntry, EvalContext, EvaluationSource, ExposureEvent, Flag, FlagDatafile, FlagEvaluationDetails, InitResult, Logger, MetricEvent, MultiContext, RuleType, SingleContext, StickyAssignment, StickyBucketService, TrackPayload, consoleLogger, consoleTransport, createLogger, noopLogger } from '@avsbhq/core';
|
|
3
3
|
import { AvsbUtils } from '@avsbhq/utils';
|
|
4
4
|
export { AvsbUtils } from '@avsbhq/utils';
|
|
5
|
+
export { StreamingClient, StreamingOptions, createStreamingClient } from '@avsbhq/utils/streaming';
|
|
5
6
|
|
|
6
7
|
interface AvsbServerOptions {
|
|
7
8
|
/** SDK key for the flag environment (e.g., "env_abc123") */
|
|
@@ -62,6 +63,32 @@ interface MiddlewareConfig {
|
|
|
62
63
|
attributesFrom?: (req: unknown) => Record<string, unknown>;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Purchase order shape for server-side purchase tracking.
|
|
68
|
+
* Mirrors the client-side snippet PurchaseOrder interface.
|
|
69
|
+
* Decimal values are forwarded as-is — no server-side currency conversion.
|
|
70
|
+
*/
|
|
71
|
+
interface PurchaseOrder {
|
|
72
|
+
/** Required. Unique order/transaction identifier. */
|
|
73
|
+
orderId: string;
|
|
74
|
+
/** Required. Order total as a decimal (e.g. 99.95). */
|
|
75
|
+
total: number;
|
|
76
|
+
/** ISO 4217 currency code. */
|
|
77
|
+
currency?: string;
|
|
78
|
+
subtotal?: number;
|
|
79
|
+
shipping?: number;
|
|
80
|
+
tax?: number;
|
|
81
|
+
discount?: number;
|
|
82
|
+
coupon?: string;
|
|
83
|
+
items?: Array<{
|
|
84
|
+
sku: string;
|
|
85
|
+
name?: string;
|
|
86
|
+
price?: number;
|
|
87
|
+
quantity?: number;
|
|
88
|
+
category?: string;
|
|
89
|
+
}>;
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
/**
|
|
66
93
|
* A per-request scoped client with a bound EvalContext.
|
|
67
94
|
*
|
|
@@ -155,6 +182,7 @@ declare class AvsbServer {
|
|
|
155
182
|
getNumberFlag(flagKey: string, defaultValue: NoInfer<number> | number, options?: GetFlagOptions): Flag<number>;
|
|
156
183
|
getJsonFlag<T>(flagKey: string, defaultValue: NoInfer<T> | T, options?: GetFlagOptions): Flag<T>;
|
|
157
184
|
getAllFlags(context: EvalContext, options?: GetAllFlagsOptions): Record<string, Flag>;
|
|
185
|
+
trackPurchase(visitorId: string, order: PurchaseOrder): Promise<void>;
|
|
158
186
|
track(eventKey: string, payload: TrackPayload & {
|
|
159
187
|
context: EvalContext;
|
|
160
188
|
}): void;
|
|
@@ -230,22 +258,8 @@ declare class RedisStickyBucketService implements StickyBucketService {
|
|
|
230
258
|
private evictIfNeeded;
|
|
231
259
|
}
|
|
232
260
|
|
|
233
|
-
interface StreamingClient {
|
|
234
|
-
connect(): void;
|
|
235
|
-
disconnect(): void;
|
|
236
|
-
isConnected(): boolean;
|
|
237
|
-
}
|
|
238
|
-
interface StreamingOptions {
|
|
239
|
-
endpoint: string;
|
|
240
|
-
sdkKey: string;
|
|
241
|
-
onDatafileUpdated: () => void;
|
|
242
|
-
logger: Logger;
|
|
243
|
-
reconnectDelay?: number;
|
|
244
|
-
}
|
|
245
|
-
declare function createStreamingClient(options: StreamingOptions): StreamingClient;
|
|
246
|
-
|
|
247
261
|
declare function createDecisionLog(): DecisionLog;
|
|
248
262
|
|
|
249
263
|
declare const SDK_VERSION = "0.3.0";
|
|
250
264
|
|
|
251
|
-
export { AvsbServer, type AvsbServerOptions, type EventOptions, type GetAllFlagsOptions, type GetFlagOptions, InMemoryStickyBucketService, type MiddlewareConfig, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type
|
|
265
|
+
export { AvsbServer, type AvsbServerOptions, type EventOptions, type GetAllFlagsOptions, type GetFlagOptions, InMemoryStickyBucketService, type MiddlewareConfig, type PurchaseOrder, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type UserBoundClient, createDecisionLog, expressMiddleware, fastifyPlugin };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { FlagDatafile, StickyBucketService, Logger, EvaluationSource, StickyAssi
|
|
|
2
2
|
export { DecideOption, DecisionLog, DecisionLogEntry, EvalContext, EvaluationSource, ExposureEvent, Flag, FlagDatafile, FlagEvaluationDetails, InitResult, Logger, MetricEvent, MultiContext, RuleType, SingleContext, StickyAssignment, StickyBucketService, TrackPayload, consoleLogger, consoleTransport, createLogger, noopLogger } from '@avsbhq/core';
|
|
3
3
|
import { AvsbUtils } from '@avsbhq/utils';
|
|
4
4
|
export { AvsbUtils } from '@avsbhq/utils';
|
|
5
|
+
export { StreamingClient, StreamingOptions, createStreamingClient } from '@avsbhq/utils/streaming';
|
|
5
6
|
|
|
6
7
|
interface AvsbServerOptions {
|
|
7
8
|
/** SDK key for the flag environment (e.g., "env_abc123") */
|
|
@@ -62,6 +63,32 @@ interface MiddlewareConfig {
|
|
|
62
63
|
attributesFrom?: (req: unknown) => Record<string, unknown>;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Purchase order shape for server-side purchase tracking.
|
|
68
|
+
* Mirrors the client-side snippet PurchaseOrder interface.
|
|
69
|
+
* Decimal values are forwarded as-is — no server-side currency conversion.
|
|
70
|
+
*/
|
|
71
|
+
interface PurchaseOrder {
|
|
72
|
+
/** Required. Unique order/transaction identifier. */
|
|
73
|
+
orderId: string;
|
|
74
|
+
/** Required. Order total as a decimal (e.g. 99.95). */
|
|
75
|
+
total: number;
|
|
76
|
+
/** ISO 4217 currency code. */
|
|
77
|
+
currency?: string;
|
|
78
|
+
subtotal?: number;
|
|
79
|
+
shipping?: number;
|
|
80
|
+
tax?: number;
|
|
81
|
+
discount?: number;
|
|
82
|
+
coupon?: string;
|
|
83
|
+
items?: Array<{
|
|
84
|
+
sku: string;
|
|
85
|
+
name?: string;
|
|
86
|
+
price?: number;
|
|
87
|
+
quantity?: number;
|
|
88
|
+
category?: string;
|
|
89
|
+
}>;
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
/**
|
|
66
93
|
* A per-request scoped client with a bound EvalContext.
|
|
67
94
|
*
|
|
@@ -155,6 +182,7 @@ declare class AvsbServer {
|
|
|
155
182
|
getNumberFlag(flagKey: string, defaultValue: NoInfer<number> | number, options?: GetFlagOptions): Flag<number>;
|
|
156
183
|
getJsonFlag<T>(flagKey: string, defaultValue: NoInfer<T> | T, options?: GetFlagOptions): Flag<T>;
|
|
157
184
|
getAllFlags(context: EvalContext, options?: GetAllFlagsOptions): Record<string, Flag>;
|
|
185
|
+
trackPurchase(visitorId: string, order: PurchaseOrder): Promise<void>;
|
|
158
186
|
track(eventKey: string, payload: TrackPayload & {
|
|
159
187
|
context: EvalContext;
|
|
160
188
|
}): void;
|
|
@@ -230,22 +258,8 @@ declare class RedisStickyBucketService implements StickyBucketService {
|
|
|
230
258
|
private evictIfNeeded;
|
|
231
259
|
}
|
|
232
260
|
|
|
233
|
-
interface StreamingClient {
|
|
234
|
-
connect(): void;
|
|
235
|
-
disconnect(): void;
|
|
236
|
-
isConnected(): boolean;
|
|
237
|
-
}
|
|
238
|
-
interface StreamingOptions {
|
|
239
|
-
endpoint: string;
|
|
240
|
-
sdkKey: string;
|
|
241
|
-
onDatafileUpdated: () => void;
|
|
242
|
-
logger: Logger;
|
|
243
|
-
reconnectDelay?: number;
|
|
244
|
-
}
|
|
245
|
-
declare function createStreamingClient(options: StreamingOptions): StreamingClient;
|
|
246
|
-
|
|
247
261
|
declare function createDecisionLog(): DecisionLog;
|
|
248
262
|
|
|
249
263
|
declare const SDK_VERSION = "0.3.0";
|
|
250
264
|
|
|
251
|
-
export { AvsbServer, type AvsbServerOptions, type EventOptions, type GetAllFlagsOptions, type GetFlagOptions, InMemoryStickyBucketService, type MiddlewareConfig, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type
|
|
265
|
+
export { AvsbServer, type AvsbServerOptions, type EventOptions, type GetAllFlagsOptions, type GetFlagOptions, InMemoryStickyBucketService, type MiddlewareConfig, type PurchaseOrder, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type UserBoundClient, createDecisionLog, expressMiddleware, fastifyPlugin };
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,9 @@ function createDecisionLog() {
|
|
|
19
19
|
return { entries };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// src/serverTracker.ts
|
|
23
|
+
import { randomUUID } from "crypto";
|
|
24
|
+
|
|
22
25
|
// src/reconnectBackoff.ts
|
|
23
26
|
var BASE_DELAY_MS = 1e3;
|
|
24
27
|
var MAX_DELAY_MS = 6e4;
|
|
@@ -47,6 +50,12 @@ function parseRetryAfter(headerValue) {
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
// src/serverTracker.ts
|
|
53
|
+
function stampExposure(event) {
|
|
54
|
+
return event.eventId ? event : { ...event, eventId: randomUUID() };
|
|
55
|
+
}
|
|
56
|
+
function stampMetric(event) {
|
|
57
|
+
return event.eventId ? event : { ...event, eventId: randomUUID() };
|
|
58
|
+
}
|
|
50
59
|
function createServerTracker(options) {
|
|
51
60
|
const {
|
|
52
61
|
sdkKey,
|
|
@@ -59,6 +68,7 @@ function createServerTracker(options) {
|
|
|
59
68
|
} = options;
|
|
60
69
|
const batchEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/batch");
|
|
61
70
|
const metricEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/metric-batch");
|
|
71
|
+
const ordersEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/orders");
|
|
62
72
|
let exposureQueue = [];
|
|
63
73
|
let metricQueue = [];
|
|
64
74
|
let flushTimer = null;
|
|
@@ -70,7 +80,7 @@ function createServerTracker(options) {
|
|
|
70
80
|
}, flushInterval);
|
|
71
81
|
function queue(event) {
|
|
72
82
|
if (destroyed) return;
|
|
73
|
-
exposureQueue.push(event);
|
|
83
|
+
exposureQueue.push(stampExposure(event));
|
|
74
84
|
if (exposureQueue.length > maxQueueSize) {
|
|
75
85
|
const dropped = exposureQueue.length - maxQueueSize;
|
|
76
86
|
exposureQueue = exposureQueue.slice(-maxQueueSize);
|
|
@@ -84,7 +94,7 @@ function createServerTracker(options) {
|
|
|
84
94
|
}
|
|
85
95
|
function queueMetric(event) {
|
|
86
96
|
if (destroyed) return;
|
|
87
|
-
metricQueue.push(event);
|
|
97
|
+
metricQueue.push(stampMetric(event));
|
|
88
98
|
if (metricQueue.length > maxQueueSize) {
|
|
89
99
|
const dropped = metricQueue.length - maxQueueSize;
|
|
90
100
|
metricQueue = metricQueue.slice(-maxQueueSize);
|
|
@@ -146,6 +156,47 @@ function createServerTracker(options) {
|
|
|
146
156
|
}
|
|
147
157
|
}
|
|
148
158
|
}
|
|
159
|
+
async function trackPurchase(visitorId, order) {
|
|
160
|
+
if (destroyed) return;
|
|
161
|
+
const payload = {
|
|
162
|
+
...order,
|
|
163
|
+
eventId: randomUUID(),
|
|
164
|
+
visitorId,
|
|
165
|
+
timestamp: Date.now(),
|
|
166
|
+
pageUrl: ""
|
|
167
|
+
};
|
|
168
|
+
const body = JSON.stringify({ sdkKey, orders: [payload] });
|
|
169
|
+
let backoff = resetBackoff();
|
|
170
|
+
let pendingRetryAfterMs = void 0;
|
|
171
|
+
for (let attempt = 0; attempt <= retryAttempts; attempt++) {
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetch(ordersEndpoint, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "Content-Type": "application/json" },
|
|
176
|
+
body,
|
|
177
|
+
signal: AbortSignal.timeout(1e4)
|
|
178
|
+
});
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
const parsed = parseRetryAfter(response.headers.get("retry-after"));
|
|
181
|
+
if (parsed !== null) pendingRetryAfterMs = parsed;
|
|
182
|
+
throw new Error(`HTTP ${response.status}`);
|
|
183
|
+
}
|
|
184
|
+
logger.debug(`Purchase order ${order.orderId} sent for visitor ${visitorId}`);
|
|
185
|
+
return;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (attempt < retryAttempts) {
|
|
188
|
+
backoff = nextBackoff(backoff, pendingRetryAfterMs);
|
|
189
|
+
pendingRetryAfterMs = void 0;
|
|
190
|
+
await new Promise((r) => setTimeout(r, backoff.delayMs));
|
|
191
|
+
} else {
|
|
192
|
+
logger.error(
|
|
193
|
+
`Failed to send purchase order ${order.orderId} after ${retryAttempts} retries`,
|
|
194
|
+
{ endpoint: ordersEndpoint, error: String(e) }
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
149
200
|
async function destroy() {
|
|
150
201
|
destroyed = true;
|
|
151
202
|
if (flushTimer) {
|
|
@@ -154,183 +205,14 @@ function createServerTracker(options) {
|
|
|
154
205
|
}
|
|
155
206
|
await flush();
|
|
156
207
|
}
|
|
157
|
-
return { queue, queueMetric, flush, destroy };
|
|
208
|
+
return { queue, queueMetric, trackPurchase, flush, destroy };
|
|
158
209
|
}
|
|
159
210
|
|
|
160
211
|
// src/streaming.ts
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
}
|
|
212
|
+
import {
|
|
213
|
+
createStreamingClient,
|
|
214
|
+
createBrowserStreamingClient
|
|
215
|
+
} from "@avsbhq/utils/streaming";
|
|
334
216
|
|
|
335
217
|
// src/version.ts
|
|
336
218
|
var SDK_VERSION = "0.3.0";
|
|
@@ -693,6 +575,16 @@ var AvsbServer = class {
|
|
|
693
575
|
return out;
|
|
694
576
|
}
|
|
695
577
|
// ---------------------------------------------------------------------------
|
|
578
|
+
// Purchase tracking — fires immediately to /v1/collect/orders
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
async trackPurchase(visitorId, order) {
|
|
581
|
+
if (!this.tracker) {
|
|
582
|
+
this.logger.warn("trackPurchase called before tracker is ready; order dropped");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
return this.tracker.trackPurchase(visitorId, order);
|
|
586
|
+
}
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
696
588
|
// §5.6 Unified track — context required
|
|
697
589
|
// ---------------------------------------------------------------------------
|
|
698
590
|
track(eventKey, payload) {
|
package/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avsbhq/node",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.cjs",
|
|
6
6
|
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.cjs"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
|
-
"files": [
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
12
17
|
"engines": {
|
|
13
18
|
"node": ">=18.0.0"
|
|
14
19
|
},
|
|
@@ -28,8 +33,8 @@
|
|
|
28
33
|
"test:watch": "vitest"
|
|
29
34
|
},
|
|
30
35
|
"dependencies": {
|
|
31
|
-
"@avsbhq/core": "1.
|
|
32
|
-
"@avsbhq/utils": "1.0.
|
|
36
|
+
"@avsbhq/core": "1.1.0",
|
|
37
|
+
"@avsbhq/utils": "1.0.1"
|
|
33
38
|
},
|
|
34
39
|
"devDependencies": {
|
|
35
40
|
"tsup": "^8.0.0",
|
|
@@ -37,4 +42,4 @@
|
|
|
37
42
|
"vitest": "^3.0.0",
|
|
38
43
|
"@types/node": "^20.0.0"
|
|
39
44
|
}
|
|
40
|
-
}
|
|
45
|
+
}
|