@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 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
- Use a **server** SDK key (prefix `sdk-server-`). Get it from your A vs B project's Environments page.
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=sdk-server-...
55
+ AVSB_SDK_KEY=sdk_production_...
56
56
  ```
57
57
 
58
- Server SDK keys grant full flag-read access. Store them in environment variables only — never in source code, client bundles, or browser-accessible config.
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
- function createStreamingClient(options) {
189
- const {
190
- endpoint,
191
- sdkKey,
192
- onDatafileUpdated,
193
- logger,
194
- reconnectDelay
195
- } = options;
196
- let connected = false;
197
- let abortController = null;
198
- let reconnectTimer = null;
199
- let backoff = resetBackoff();
200
- let pendingRetryAfterMs = void 0;
201
- function connect() {
202
- if (connected) return;
203
- void startConnection();
204
- }
205
- function disconnect() {
206
- connected = false;
207
- if (abortController) {
208
- abortController.abort();
209
- abortController = null;
210
- }
211
- if (reconnectTimer) {
212
- clearTimeout(reconnectTimer);
213
- reconnectTimer = null;
214
- }
215
- }
216
- function isConnected() {
217
- return connected;
218
- }
219
- async function fetchTicket(signal) {
220
- const ticketUrl = `${endpoint}?sdkKey=${encodeURIComponent(sdkKey)}`;
221
- try {
222
- const res = await fetch(ticketUrl, { signal });
223
- if (!res.ok) {
224
- logger.warn("SSE ticket fetch failed", { status: res.status, url: ticketUrl });
225
- return null;
226
- }
227
- const body = await res.json();
228
- if (!body?.url || !body?.token) {
229
- logger.warn("SSE ticket response missing fields");
230
- return null;
231
- }
232
- return body;
233
- } catch (e) {
234
- if (signal.aborted) return null;
235
- logger.warn("SSE ticket fetch error", { error: String(e) });
236
- return null;
237
- }
238
- }
239
- async function startConnection() {
240
- abortController = new AbortController();
241
- const signal = abortController.signal;
242
- const ticket = await fetchTicket(signal);
243
- if (!ticket || signal.aborted) {
244
- if (!signal.aborted) scheduleReconnect();
245
- return;
246
- }
247
- const streamUrl = `${ticket.url}?sdkKey=${encodeURIComponent(sdkKey)}&token=${encodeURIComponent(ticket.token)}`;
248
- try {
249
- const response = await fetch(streamUrl, {
250
- headers: { Accept: "text/event-stream" },
251
- signal
252
- });
253
- if (response.status === 401) {
254
- logger.warn("SSE stream rejected token; refetching ticket");
255
- const next = await fetchTicket(signal);
256
- if (!next || signal.aborted) {
257
- if (!signal.aborted) scheduleReconnect();
258
- return;
259
- }
260
- const retryUrl = `${next.url}?sdkKey=${encodeURIComponent(sdkKey)}&token=${encodeURIComponent(next.token)}`;
261
- const retry = await fetch(retryUrl, {
262
- headers: { Accept: "text/event-stream" },
263
- signal
264
- });
265
- if (!retry.ok || !retry.body) {
266
- logger.warn("SSE retry failed", { status: retry.status });
267
- recordRetryAfter(retry.headers.get("retry-after"));
268
- scheduleReconnect();
269
- return;
270
- }
271
- await pumpFrames(retry, signal);
272
- return;
273
- }
274
- if (response.status === 429) {
275
- recordRetryAfter(response.headers.get("retry-after"));
276
- logger.warn("SSE rate-limited", { status: 429 });
277
- scheduleReconnect();
278
- return;
279
- }
280
- if (!response.ok) {
281
- logger.warn("SSE connection failed", { status: response.status, url: streamUrl });
282
- recordRetryAfter(response.headers.get("retry-after"));
283
- scheduleReconnect();
284
- return;
285
- }
286
- if (!response.body) {
287
- logger.warn("SSE response has no body");
288
- scheduleReconnect();
289
- return;
290
- }
291
- await pumpFrames(response, signal);
292
- } catch (e) {
293
- if (signal.aborted) return;
294
- logger.warn("SSE connection error", { error: String(e) });
295
- connected = false;
296
- scheduleReconnect();
297
- }
298
- }
299
- function recordRetryAfter(headerValue) {
300
- const parsed = parseRetryAfter(headerValue);
301
- if (parsed !== null) pendingRetryAfterMs = parsed;
302
- }
303
- async function pumpFrames(response, signal) {
304
- connected = true;
305
- backoff = resetBackoff();
306
- pendingRetryAfterMs = void 0;
307
- logger.info(`SSE connected to ${endpoint}`);
308
- const reader = response.body.getReader();
309
- const decoder = new TextDecoder();
310
- let buffer = "";
311
- try {
312
- while (true) {
313
- const { done, value } = await reader.read();
314
- if (done) break;
315
- buffer += decoder.decode(value, { stream: true });
316
- const lines = buffer.split("\n");
317
- buffer = lines.pop() ?? "";
318
- for (const line of lines) {
319
- if (line.startsWith("data: ")) {
320
- const data = line.slice(6).trim();
321
- if (!data) continue;
322
- try {
323
- const parsed = JSON.parse(data);
324
- if (parsed.type === "datafile_updated") {
325
- onDatafileUpdated();
326
- }
327
- } catch {
328
- logger.warn("Failed to parse SSE data", { data });
329
- }
330
- }
331
- }
332
- }
333
- } catch (e) {
334
- if (signal.aborted) return;
335
- logger.warn("SSE stream read error", { error: String(e) });
336
- }
337
- connected = false;
338
- scheduleReconnect();
339
- }
340
- function scheduleReconnect() {
341
- if (reconnectTimer) return;
342
- if (typeof reconnectDelay === "number") {
343
- reconnectTimer = setTimeout(() => {
344
- reconnectTimer = null;
345
- void startConnection();
346
- }, reconnectDelay);
347
- return;
348
- }
349
- backoff = nextBackoff(backoff, pendingRetryAfterMs);
350
- pendingRetryAfterMs = void 0;
351
- logger.warn(
352
- `SSE reconnect attempt ${backoff.attempt} in ${backoff.delayMs}ms`
353
- );
354
- reconnectTimer = setTimeout(() => {
355
- reconnectTimer = null;
356
- void startConnection();
357
- }, backoff.delayMs);
358
- }
359
- return { connect, disconnect, isConnected };
360
- }
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 StreamingClient, type StreamingOptions, type UserBoundClient, createDecisionLog, createStreamingClient, expressMiddleware, fastifyPlugin };
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 StreamingClient, type StreamingOptions, type UserBoundClient, createDecisionLog, createStreamingClient, expressMiddleware, fastifyPlugin };
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
- 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
- }
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.0.0",
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
- ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" }
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.cjs"
12
+ }
10
13
  },
11
- "files": ["dist"],
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.0.0",
32
- "@avsbhq/utils": "1.0.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
+ }