@avsbhq/node 1.0.0 → 1.5.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 +36 -3
- package/dist/index.cjs +355 -179
- package/dist/index.d.cts +179 -17
- package/dist/index.d.ts +179 -17
- package/dist/index.js +356 -177
- 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
|
|
|
@@ -93,6 +93,35 @@ const flag = server.getBoolFlag('checkout-v2', false, {
|
|
|
93
93
|
})
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
+
### Commerce targeting attributes
|
|
97
|
+
|
|
98
|
+
Audiences built with the commerce condition types (cart value, products viewed, purchase history) evaluate from attributes you supply on the context. The attribute names are fixed:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const flag = server.getBoolFlag('big-cart-banner', false, {
|
|
102
|
+
context: {
|
|
103
|
+
kind: 'user',
|
|
104
|
+
key: 'u_123',
|
|
105
|
+
cartValue: 120.5, // decimal MAJOR units of the project currency
|
|
106
|
+
viewedSkus: ['SKU-1', 'SKU-2'], // for "products viewed" matching on SKU
|
|
107
|
+
viewedCategories: ['trainers'], // for "products viewed" matching on category
|
|
108
|
+
purchaseHistory: {
|
|
109
|
+
// PurchaseHistoryAttribute — exported for your own declarations
|
|
110
|
+
hasPurchased: true,
|
|
111
|
+
lastPurchaseAt: 1765500000000, // epoch milliseconds (optional)
|
|
112
|
+
lifetimeSpend: 540.0, // decimal MAJOR units (optional)
|
|
113
|
+
orderCount: 7, // optional
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Server-side semantics to be aware of:
|
|
120
|
+
|
|
121
|
+
- **All money is decimal major units.** Condition values are stored in minor units and converted using the project currency exponent from the datafile (default 2) — you never pass minor units.
|
|
122
|
+
- **Missing or mistyped attributes evaluate the condition to `false`.** A completely absent `purchaseHistory` makes ALL purchase-history operators false — including `has_not_purchased`. On the server, absence means "not provided", not "never purchased" (this deliberately diverges from the browser snippet, where a profile miss is authoritative).
|
|
123
|
+
- **Dataset-backed "products viewed" conditions evaluate `false` on the server.** Dataset view windows live in the browser; server callers use literal SKU/category lists. The session/30-day window distinction is also browser-only — supply whatever view list you consider current.
|
|
124
|
+
|
|
96
125
|
### `alias(previousContext, newContext)`
|
|
97
126
|
|
|
98
127
|
Send an alias event linking two identities in the analytics pipeline. Typically called server-side when a user signs up or links an anonymous session to an account:
|
|
@@ -104,6 +133,10 @@ await server.alias(
|
|
|
104
133
|
)
|
|
105
134
|
```
|
|
106
135
|
|
|
136
|
+
### Sharing identity with the web snippet
|
|
137
|
+
|
|
138
|
+
To measure one metric across both the A vs B web snippet and this SDK (the setup behind cross-project metrics), the context `key` must equal the browser snippet's visitor id — the `_avsb_visitor` cookie UUID returned by `avsb.getVisitorId()`. Results join on raw id equality, so a different key means the two surfaces never line up. Forward the snippet id from the browser and use it as the key here, or link an internal account id to it with `alias()`. Full guide: [Shared Visitor Identity](https://avsb.cloud/docs/developer-reference/shared-visitor-identity).
|
|
139
|
+
|
|
107
140
|
---
|
|
108
141
|
|
|
109
142
|
## 5. Multi-context
|
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
|
|
@@ -40,12 +40,266 @@ module.exports = __toCommonJS(index_exports);
|
|
|
40
40
|
var import_engine = require("@avsbhq/core/engine");
|
|
41
41
|
var import_core2 = require("@avsbhq/core");
|
|
42
42
|
|
|
43
|
+
// src/datasets.ts
|
|
44
|
+
var DEFAULT_REQUEST_TIMEOUT = 1e4;
|
|
45
|
+
function miss(slug, version) {
|
|
46
|
+
return { found: false, meta: { slug, version } };
|
|
47
|
+
}
|
|
48
|
+
function createDatasetsClient(options) {
|
|
49
|
+
const { sdkKey, getDatafile, logger } = options;
|
|
50
|
+
const requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
|
|
51
|
+
async function get(slug, key) {
|
|
52
|
+
if (!slug || !key) {
|
|
53
|
+
logger.warn("datasets.get called with an empty slug or key \u2014 returning not found");
|
|
54
|
+
return miss(slug, null);
|
|
55
|
+
}
|
|
56
|
+
const datafile = getDatafile();
|
|
57
|
+
if (!datafile) {
|
|
58
|
+
logger.debug(`SDK not ready, returning not found for dataset "${slug}"`);
|
|
59
|
+
return miss(slug, null);
|
|
60
|
+
}
|
|
61
|
+
const dataset = datafile.datasets?.find((d) => d.slug === slug);
|
|
62
|
+
if (!dataset) {
|
|
63
|
+
logger.debug(`Dataset "${slug}" not present in datafile \u2014 returning not found`);
|
|
64
|
+
return miss(slug, null);
|
|
65
|
+
}
|
|
66
|
+
if (!dataset.liveVersion) {
|
|
67
|
+
logger.debug(`Dataset "${slug}" has no live version \u2014 returning not found`);
|
|
68
|
+
return miss(slug, null);
|
|
69
|
+
}
|
|
70
|
+
const base = datafile.collectEndpoint.replace(/\/+$/, "");
|
|
71
|
+
const url = `${base}/v1/ds/${encodeURIComponent(sdkKey)}/${encodeURIComponent(slug)}/${dataset.liveVersion}/${encodeURIComponent(key)}`;
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(requestTimeout) });
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
logger.warn("Dataset lookup failed", { slug, key, status: res.status });
|
|
76
|
+
return miss(slug, dataset.liveVersion);
|
|
77
|
+
}
|
|
78
|
+
const body = await res.json();
|
|
79
|
+
if (!body || body.found !== true) {
|
|
80
|
+
return miss(slug, dataset.liveVersion);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
found: true,
|
|
84
|
+
value: body.value,
|
|
85
|
+
meta: { slug, version: dataset.liveVersion }
|
|
86
|
+
};
|
|
87
|
+
} catch (e) {
|
|
88
|
+
logger.warn("Dataset lookup error", { slug, key, error: String(e) });
|
|
89
|
+
return miss(slug, dataset.liveVersion);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { get };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/recs.ts
|
|
96
|
+
var DEFAULT_REQUEST_TIMEOUT2 = 1e4;
|
|
97
|
+
var MAX_SEED_CHARS = 256;
|
|
98
|
+
var MAX_SEEDS = 3;
|
|
99
|
+
var MAX_ITEMS_CAP = 24;
|
|
100
|
+
var DEFAULT_MAX_ITEMS = 8;
|
|
101
|
+
var MAX_ID_CHARS = 64;
|
|
102
|
+
var MAX_ATTR_VALUE_CHARS = 512;
|
|
103
|
+
var GLOBAL_SEED = "_global";
|
|
104
|
+
function clampMaxItems(value) {
|
|
105
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return DEFAULT_MAX_ITEMS;
|
|
106
|
+
return Math.min(Math.floor(value), MAX_ITEMS_CAP);
|
|
107
|
+
}
|
|
108
|
+
function asText(value) {
|
|
109
|
+
if (typeof value === "string") return value;
|
|
110
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
function joinIds(ids) {
|
|
114
|
+
let out = "";
|
|
115
|
+
for (const id of ids) {
|
|
116
|
+
const next = out === "" ? id : `${out},${id}`;
|
|
117
|
+
if (next.length > MAX_ATTR_VALUE_CHARS) break;
|
|
118
|
+
out = next;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
function toRecProducts(items, cap) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const item of items) {
|
|
125
|
+
if (out.length >= cap) break;
|
|
126
|
+
if (item === null || typeof item !== "object") continue;
|
|
127
|
+
const fields = item;
|
|
128
|
+
const id = (asText(fields.id) || asText(fields.sku)).slice(0, MAX_ID_CHARS);
|
|
129
|
+
if (id === "") continue;
|
|
130
|
+
const product = { id };
|
|
131
|
+
const title = asText(fields.title);
|
|
132
|
+
if (title) product.title = title;
|
|
133
|
+
const image = asText(fields.image);
|
|
134
|
+
if (image) product.image = image;
|
|
135
|
+
const href = asText(fields.href) || asText(fields.url);
|
|
136
|
+
if (href && !/^\s*javascript:/i.test(href)) product.href = href;
|
|
137
|
+
const category = asText(fields.category);
|
|
138
|
+
if (category) product.category = category;
|
|
139
|
+
if (typeof fields.price === "number" && Number.isFinite(fields.price)) {
|
|
140
|
+
product.price = Math.round(fields.price);
|
|
141
|
+
}
|
|
142
|
+
if (typeof fields.compareAtPrice === "number" && Number.isFinite(fields.compareAtPrice)) {
|
|
143
|
+
product.compareAtPrice = Math.round(fields.compareAtPrice);
|
|
144
|
+
}
|
|
145
|
+
if (typeof fields.currency === "string" && fields.currency !== "") {
|
|
146
|
+
product.currency = fields.currency;
|
|
147
|
+
}
|
|
148
|
+
const availability = fields.availability;
|
|
149
|
+
if (availability === "in_stock" || availability === "out_of_stock" || availability === "removed") {
|
|
150
|
+
product.availability = availability;
|
|
151
|
+
}
|
|
152
|
+
out.push(product);
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
function miss2(recipe, degraded) {
|
|
157
|
+
return { items: [], served: false, recipe, source: null, fallbackStep: null, degraded };
|
|
158
|
+
}
|
|
159
|
+
function createRecsClient(options) {
|
|
160
|
+
const { sdkKey, getDatafile, getTracker, logger } = options;
|
|
161
|
+
const requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT2;
|
|
162
|
+
function resolveSeeds(context) {
|
|
163
|
+
const clip = (s) => s.slice(0, MAX_SEED_CHARS);
|
|
164
|
+
if (Array.isArray(context?.seeds)) {
|
|
165
|
+
const seeds = context.seeds.filter((s) => typeof s === "string" && s !== "").slice(0, MAX_SEEDS).map(clip);
|
|
166
|
+
if (seeds.length > 0) return seeds;
|
|
167
|
+
}
|
|
168
|
+
if (typeof context?.seed === "string" && context.seed !== "") return [clip(context.seed)];
|
|
169
|
+
return [GLOBAL_SEED];
|
|
170
|
+
}
|
|
171
|
+
async function get(req) {
|
|
172
|
+
try {
|
|
173
|
+
const recipeHandle = typeof req?.recipe === "string" ? req.recipe : "";
|
|
174
|
+
if (recipeHandle === "") {
|
|
175
|
+
logger.warn("recs.get called with an empty recipe \u2014 returning served:false");
|
|
176
|
+
return miss2("", false);
|
|
177
|
+
}
|
|
178
|
+
const datafile = getDatafile();
|
|
179
|
+
if (!datafile) {
|
|
180
|
+
logger.debug(`SDK not ready, returning served:false for recipe "${recipeHandle}"`);
|
|
181
|
+
return miss2(recipeHandle, false);
|
|
182
|
+
}
|
|
183
|
+
const recipes = datafile.recipes;
|
|
184
|
+
const entry = recipes?.find((r) => r.recipe === recipeHandle) ?? null;
|
|
185
|
+
if (!entry) {
|
|
186
|
+
logger.debug(`Recipe "${recipeHandle}" not present in datafile \u2014 returning served:false`);
|
|
187
|
+
return miss2(recipeHandle, false);
|
|
188
|
+
}
|
|
189
|
+
const seeds = resolveSeeds(req.context);
|
|
190
|
+
const maxItems = clampMaxItems(req.maxItems);
|
|
191
|
+
const excludeOutOfStock = typeof req.excludeOutOfStock === "boolean" ? req.excludeOutOfStock : entry.excludeOutOfStock;
|
|
192
|
+
const body = {
|
|
193
|
+
snippetKey: sdkKey,
|
|
194
|
+
assigned: 0,
|
|
195
|
+
sources: [{ chain: entry.chain }],
|
|
196
|
+
maxItems,
|
|
197
|
+
excludeOutOfStock,
|
|
198
|
+
merch: entry.merch ?? [],
|
|
199
|
+
sessionAffinity: entry.sessionAffinity
|
|
200
|
+
};
|
|
201
|
+
if (seeds.length === 1) body.seedKey = seeds[0];
|
|
202
|
+
else body.seedKeys = seeds;
|
|
203
|
+
const base = datafile.collectEndpoint.replace(/\/+$/, "").replace("/v1/collect/batch", "");
|
|
204
|
+
const res = await fetch(`${base}/v1/recs/resolve`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: { "Content-Type": "application/json" },
|
|
207
|
+
body: JSON.stringify(body),
|
|
208
|
+
signal: AbortSignal.timeout(requestTimeout)
|
|
209
|
+
});
|
|
210
|
+
if (!res.ok) {
|
|
211
|
+
logger.warn("Recs resolve failed", { recipe: recipeHandle, status: res.status });
|
|
212
|
+
return miss2(recipeHandle, true);
|
|
213
|
+
}
|
|
214
|
+
const parsed = await res.json();
|
|
215
|
+
const data = parsed?.data;
|
|
216
|
+
if (!data || typeof data !== "object") return miss2(recipeHandle, true);
|
|
217
|
+
const items = toRecProducts(Array.isArray(data.items) ? data.items : [], maxItems);
|
|
218
|
+
const servedStep = data.served ?? null;
|
|
219
|
+
const served = servedStep !== null && items.length > 0;
|
|
220
|
+
if (req.attribution) {
|
|
221
|
+
const tracker = getTracker();
|
|
222
|
+
if (tracker) {
|
|
223
|
+
tracker.queue({
|
|
224
|
+
visitorId: req.attribution.visitorId,
|
|
225
|
+
experimentId: req.attribution.experimentId,
|
|
226
|
+
variationId: req.attribution.variationId,
|
|
227
|
+
eventName: "rec:impression",
|
|
228
|
+
revenue: null,
|
|
229
|
+
timestamp: Date.now(),
|
|
230
|
+
pageUrl: "",
|
|
231
|
+
attributes: {
|
|
232
|
+
recipe: entry.recipe,
|
|
233
|
+
seed: joinIds(seeds),
|
|
234
|
+
slug: servedStep?.slug ?? "",
|
|
235
|
+
version: servedStep ? String(servedStep.version) : "",
|
|
236
|
+
step: servedStep ? String(servedStep.step) : "",
|
|
237
|
+
served: served ? "true" : "false",
|
|
238
|
+
items: joinIds(items.map((i) => i.id))
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
} else {
|
|
242
|
+
logger.warn("recs.get attribution supplied before tracker is ready; impression dropped");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
items,
|
|
247
|
+
served,
|
|
248
|
+
recipe: entry.recipe,
|
|
249
|
+
source: servedStep?.slug ?? null,
|
|
250
|
+
fallbackStep: servedStep?.step ?? null,
|
|
251
|
+
degraded: false
|
|
252
|
+
};
|
|
253
|
+
} catch (e) {
|
|
254
|
+
logger.warn("Recs resolve error", {
|
|
255
|
+
recipe: typeof req?.recipe === "string" ? req.recipe : "",
|
|
256
|
+
error: String(e)
|
|
257
|
+
});
|
|
258
|
+
return miss2(typeof req?.recipe === "string" ? req.recipe : "", true);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function trackClick(productId, opts) {
|
|
262
|
+
if (typeof productId !== "string" || productId === "") {
|
|
263
|
+
logger.warn("recs.trackClick called with an empty productId \u2014 dropped");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!opts || !opts.visitorId || !opts.experimentId || !opts.variationId) {
|
|
267
|
+
logger.warn("recs.trackClick requires visitorId/experimentId/variationId \u2014 dropped");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const tracker = getTracker();
|
|
271
|
+
if (!tracker) {
|
|
272
|
+
logger.warn("recs.trackClick called before tracker is ready; click dropped");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const position = typeof opts.position === "number" && Number.isInteger(opts.position) && opts.position >= 1 ? String(opts.position) : "";
|
|
276
|
+
tracker.queue({
|
|
277
|
+
visitorId: opts.visitorId,
|
|
278
|
+
experimentId: opts.experimentId,
|
|
279
|
+
variationId: opts.variationId,
|
|
280
|
+
eventName: "rec:click",
|
|
281
|
+
revenue: null,
|
|
282
|
+
timestamp: Date.now(),
|
|
283
|
+
pageUrl: "",
|
|
284
|
+
attributes: {
|
|
285
|
+
recipe: typeof opts.recipe === "string" ? opts.recipe : "",
|
|
286
|
+
item: productId.slice(0, MAX_ID_CHARS),
|
|
287
|
+
position
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return { get, trackClick };
|
|
292
|
+
}
|
|
293
|
+
|
|
43
294
|
// src/decisionLog.ts
|
|
44
295
|
function createDecisionLog() {
|
|
45
296
|
const entries = [];
|
|
46
297
|
return { entries };
|
|
47
298
|
}
|
|
48
299
|
|
|
300
|
+
// src/serverTracker.ts
|
|
301
|
+
var import_node_crypto = require("crypto");
|
|
302
|
+
|
|
49
303
|
// src/reconnectBackoff.ts
|
|
50
304
|
var BASE_DELAY_MS = 1e3;
|
|
51
305
|
var MAX_DELAY_MS = 6e4;
|
|
@@ -74,6 +328,12 @@ function parseRetryAfter(headerValue) {
|
|
|
74
328
|
}
|
|
75
329
|
|
|
76
330
|
// src/serverTracker.ts
|
|
331
|
+
function stampExposure(event) {
|
|
332
|
+
return event.eventId ? event : { ...event, eventId: (0, import_node_crypto.randomUUID)() };
|
|
333
|
+
}
|
|
334
|
+
function stampMetric(event) {
|
|
335
|
+
return event.eventId ? event : { ...event, eventId: (0, import_node_crypto.randomUUID)() };
|
|
336
|
+
}
|
|
77
337
|
function createServerTracker(options) {
|
|
78
338
|
const {
|
|
79
339
|
sdkKey,
|
|
@@ -86,6 +346,7 @@ function createServerTracker(options) {
|
|
|
86
346
|
} = options;
|
|
87
347
|
const batchEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/batch");
|
|
88
348
|
const metricEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/metric-batch");
|
|
349
|
+
const ordersEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/orders");
|
|
89
350
|
let exposureQueue = [];
|
|
90
351
|
let metricQueue = [];
|
|
91
352
|
let flushTimer = null;
|
|
@@ -97,7 +358,7 @@ function createServerTracker(options) {
|
|
|
97
358
|
}, flushInterval);
|
|
98
359
|
function queue(event) {
|
|
99
360
|
if (destroyed) return;
|
|
100
|
-
exposureQueue.push(event);
|
|
361
|
+
exposureQueue.push(stampExposure(event));
|
|
101
362
|
if (exposureQueue.length > maxQueueSize) {
|
|
102
363
|
const dropped = exposureQueue.length - maxQueueSize;
|
|
103
364
|
exposureQueue = exposureQueue.slice(-maxQueueSize);
|
|
@@ -111,7 +372,7 @@ function createServerTracker(options) {
|
|
|
111
372
|
}
|
|
112
373
|
function queueMetric(event) {
|
|
113
374
|
if (destroyed) return;
|
|
114
|
-
metricQueue.push(event);
|
|
375
|
+
metricQueue.push(stampMetric(event));
|
|
115
376
|
if (metricQueue.length > maxQueueSize) {
|
|
116
377
|
const dropped = metricQueue.length - maxQueueSize;
|
|
117
378
|
metricQueue = metricQueue.slice(-maxQueueSize);
|
|
@@ -173,6 +434,47 @@ function createServerTracker(options) {
|
|
|
173
434
|
}
|
|
174
435
|
}
|
|
175
436
|
}
|
|
437
|
+
async function trackPurchase(visitorId, order) {
|
|
438
|
+
if (destroyed) return;
|
|
439
|
+
const payload = {
|
|
440
|
+
...order,
|
|
441
|
+
eventId: (0, import_node_crypto.randomUUID)(),
|
|
442
|
+
visitorId,
|
|
443
|
+
timestamp: Date.now(),
|
|
444
|
+
pageUrl: ""
|
|
445
|
+
};
|
|
446
|
+
const body = JSON.stringify({ sdkKey, orders: [payload] });
|
|
447
|
+
let backoff = resetBackoff();
|
|
448
|
+
let pendingRetryAfterMs = void 0;
|
|
449
|
+
for (let attempt = 0; attempt <= retryAttempts; attempt++) {
|
|
450
|
+
try {
|
|
451
|
+
const response = await fetch(ordersEndpoint, {
|
|
452
|
+
method: "POST",
|
|
453
|
+
headers: { "Content-Type": "application/json" },
|
|
454
|
+
body,
|
|
455
|
+
signal: AbortSignal.timeout(1e4)
|
|
456
|
+
});
|
|
457
|
+
if (!response.ok) {
|
|
458
|
+
const parsed = parseRetryAfter(response.headers.get("retry-after"));
|
|
459
|
+
if (parsed !== null) pendingRetryAfterMs = parsed;
|
|
460
|
+
throw new Error(`HTTP ${response.status}`);
|
|
461
|
+
}
|
|
462
|
+
logger.debug(`Purchase order ${order.orderId} sent for visitor ${visitorId}`);
|
|
463
|
+
return;
|
|
464
|
+
} catch (e) {
|
|
465
|
+
if (attempt < retryAttempts) {
|
|
466
|
+
backoff = nextBackoff(backoff, pendingRetryAfterMs);
|
|
467
|
+
pendingRetryAfterMs = void 0;
|
|
468
|
+
await new Promise((r) => setTimeout(r, backoff.delayMs));
|
|
469
|
+
} else {
|
|
470
|
+
logger.error(
|
|
471
|
+
`Failed to send purchase order ${order.orderId} after ${retryAttempts} retries`,
|
|
472
|
+
{ endpoint: ordersEndpoint, error: String(e) }
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
176
478
|
async function destroy() {
|
|
177
479
|
destroyed = true;
|
|
178
480
|
if (flushTimer) {
|
|
@@ -181,186 +483,14 @@ function createServerTracker(options) {
|
|
|
181
483
|
}
|
|
182
484
|
await flush();
|
|
183
485
|
}
|
|
184
|
-
return { queue, queueMetric, flush, destroy };
|
|
486
|
+
return { queue, queueMetric, trackPurchase, flush, destroy };
|
|
185
487
|
}
|
|
186
488
|
|
|
187
489
|
// 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
|
-
}
|
|
490
|
+
var import_streaming = require("@avsbhq/utils/streaming");
|
|
361
491
|
|
|
362
492
|
// src/version.ts
|
|
363
|
-
var SDK_VERSION = "0.
|
|
493
|
+
var SDK_VERSION = "0.4.0";
|
|
364
494
|
|
|
365
495
|
// src/UserBoundClient.ts
|
|
366
496
|
function createUserBoundClient(server, context) {
|
|
@@ -536,6 +666,42 @@ var AvsbServer = class {
|
|
|
536
666
|
}
|
|
537
667
|
return this._utils;
|
|
538
668
|
}
|
|
669
|
+
/**
|
|
670
|
+
* Server-side datasets read surface (e-commerce suite Phase 2).
|
|
671
|
+
* Constructed lazily on first access, mirroring the `utils` getter.
|
|
672
|
+
* `client.datasets.get(slug, key)` resolves the live version from the
|
|
673
|
+
* current datafile and never throws — see ./datasets.ts.
|
|
674
|
+
*/
|
|
675
|
+
_datasets = null;
|
|
676
|
+
get datasets() {
|
|
677
|
+
if (!this._datasets) {
|
|
678
|
+
this._datasets = createDatasetsClient({
|
|
679
|
+
sdkKey: this.sdkKey,
|
|
680
|
+
getDatafile: () => this.datafile,
|
|
681
|
+
logger: this.logger
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
return this._datasets;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Server-side recommendations surface (e-commerce suite overhaul, C3).
|
|
688
|
+
* Constructed lazily on first access, mirroring the `datasets` getter.
|
|
689
|
+
* `client.recs.get({ recipe, … })` resolves the recipe registry from the
|
|
690
|
+
* current datafile and never throws; impressions/clicks ride the server
|
|
691
|
+
* tracker's queue and revenue attaches via the existing `trackPurchase`.
|
|
692
|
+
*/
|
|
693
|
+
_recs = null;
|
|
694
|
+
get recs() {
|
|
695
|
+
if (!this._recs) {
|
|
696
|
+
this._recs = createRecsClient({
|
|
697
|
+
sdkKey: this.sdkKey,
|
|
698
|
+
getDatafile: () => this.datafile,
|
|
699
|
+
getTracker: () => this.tracker,
|
|
700
|
+
logger: this.logger
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
return this._recs;
|
|
704
|
+
}
|
|
539
705
|
constructor(options) {
|
|
540
706
|
this.sdkKey = options.sdkKey;
|
|
541
707
|
this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
|
|
@@ -720,6 +886,16 @@ var AvsbServer = class {
|
|
|
720
886
|
return out;
|
|
721
887
|
}
|
|
722
888
|
// ---------------------------------------------------------------------------
|
|
889
|
+
// Purchase tracking — fires immediately to /v1/collect/orders
|
|
890
|
+
// ---------------------------------------------------------------------------
|
|
891
|
+
async trackPurchase(visitorId, order) {
|
|
892
|
+
if (!this.tracker) {
|
|
893
|
+
this.logger.warn("trackPurchase called before tracker is ready; order dropped");
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
return this.tracker.trackPurchase(visitorId, order);
|
|
897
|
+
}
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
723
899
|
// §5.6 Unified track — context required
|
|
724
900
|
// ---------------------------------------------------------------------------
|
|
725
901
|
track(eventKey, payload) {
|
|
@@ -1101,7 +1277,7 @@ var AvsbServer = class {
|
|
|
1101
1277
|
}
|
|
1102
1278
|
startStreamingIfEnabled() {
|
|
1103
1279
|
if (!this.streamingEnabled || this.streamingClient || this.destroyed) return;
|
|
1104
|
-
this.streamingClient = createStreamingClient({
|
|
1280
|
+
this.streamingClient = (0, import_streaming.createStreamingClient)({
|
|
1105
1281
|
endpoint: this.streamingEndpoint,
|
|
1106
1282
|
sdkKey: this.sdkKey,
|
|
1107
1283
|
onDatafileUpdated: () => {
|