@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/dist/index.js
CHANGED
|
@@ -13,12 +13,266 @@ import {
|
|
|
13
13
|
noopLogger
|
|
14
14
|
} from "@avsbhq/core";
|
|
15
15
|
|
|
16
|
+
// src/datasets.ts
|
|
17
|
+
var DEFAULT_REQUEST_TIMEOUT = 1e4;
|
|
18
|
+
function miss(slug, version) {
|
|
19
|
+
return { found: false, meta: { slug, version } };
|
|
20
|
+
}
|
|
21
|
+
function createDatasetsClient(options) {
|
|
22
|
+
const { sdkKey, getDatafile, logger } = options;
|
|
23
|
+
const requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
|
|
24
|
+
async function get(slug, key) {
|
|
25
|
+
if (!slug || !key) {
|
|
26
|
+
logger.warn("datasets.get called with an empty slug or key \u2014 returning not found");
|
|
27
|
+
return miss(slug, null);
|
|
28
|
+
}
|
|
29
|
+
const datafile = getDatafile();
|
|
30
|
+
if (!datafile) {
|
|
31
|
+
logger.debug(`SDK not ready, returning not found for dataset "${slug}"`);
|
|
32
|
+
return miss(slug, null);
|
|
33
|
+
}
|
|
34
|
+
const dataset = datafile.datasets?.find((d) => d.slug === slug);
|
|
35
|
+
if (!dataset) {
|
|
36
|
+
logger.debug(`Dataset "${slug}" not present in datafile \u2014 returning not found`);
|
|
37
|
+
return miss(slug, null);
|
|
38
|
+
}
|
|
39
|
+
if (!dataset.liveVersion) {
|
|
40
|
+
logger.debug(`Dataset "${slug}" has no live version \u2014 returning not found`);
|
|
41
|
+
return miss(slug, null);
|
|
42
|
+
}
|
|
43
|
+
const base = datafile.collectEndpoint.replace(/\/+$/, "");
|
|
44
|
+
const url = `${base}/v1/ds/${encodeURIComponent(sdkKey)}/${encodeURIComponent(slug)}/${dataset.liveVersion}/${encodeURIComponent(key)}`;
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(requestTimeout) });
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
logger.warn("Dataset lookup failed", { slug, key, status: res.status });
|
|
49
|
+
return miss(slug, dataset.liveVersion);
|
|
50
|
+
}
|
|
51
|
+
const body = await res.json();
|
|
52
|
+
if (!body || body.found !== true) {
|
|
53
|
+
return miss(slug, dataset.liveVersion);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
found: true,
|
|
57
|
+
value: body.value,
|
|
58
|
+
meta: { slug, version: dataset.liveVersion }
|
|
59
|
+
};
|
|
60
|
+
} catch (e) {
|
|
61
|
+
logger.warn("Dataset lookup error", { slug, key, error: String(e) });
|
|
62
|
+
return miss(slug, dataset.liveVersion);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { get };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/recs.ts
|
|
69
|
+
var DEFAULT_REQUEST_TIMEOUT2 = 1e4;
|
|
70
|
+
var MAX_SEED_CHARS = 256;
|
|
71
|
+
var MAX_SEEDS = 3;
|
|
72
|
+
var MAX_ITEMS_CAP = 24;
|
|
73
|
+
var DEFAULT_MAX_ITEMS = 8;
|
|
74
|
+
var MAX_ID_CHARS = 64;
|
|
75
|
+
var MAX_ATTR_VALUE_CHARS = 512;
|
|
76
|
+
var GLOBAL_SEED = "_global";
|
|
77
|
+
function clampMaxItems(value) {
|
|
78
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return DEFAULT_MAX_ITEMS;
|
|
79
|
+
return Math.min(Math.floor(value), MAX_ITEMS_CAP);
|
|
80
|
+
}
|
|
81
|
+
function asText(value) {
|
|
82
|
+
if (typeof value === "string") return value;
|
|
83
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
function joinIds(ids) {
|
|
87
|
+
let out = "";
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
const next = out === "" ? id : `${out},${id}`;
|
|
90
|
+
if (next.length > MAX_ATTR_VALUE_CHARS) break;
|
|
91
|
+
out = next;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
function toRecProducts(items, cap) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const item of items) {
|
|
98
|
+
if (out.length >= cap) break;
|
|
99
|
+
if (item === null || typeof item !== "object") continue;
|
|
100
|
+
const fields = item;
|
|
101
|
+
const id = (asText(fields.id) || asText(fields.sku)).slice(0, MAX_ID_CHARS);
|
|
102
|
+
if (id === "") continue;
|
|
103
|
+
const product = { id };
|
|
104
|
+
const title = asText(fields.title);
|
|
105
|
+
if (title) product.title = title;
|
|
106
|
+
const image = asText(fields.image);
|
|
107
|
+
if (image) product.image = image;
|
|
108
|
+
const href = asText(fields.href) || asText(fields.url);
|
|
109
|
+
if (href && !/^\s*javascript:/i.test(href)) product.href = href;
|
|
110
|
+
const category = asText(fields.category);
|
|
111
|
+
if (category) product.category = category;
|
|
112
|
+
if (typeof fields.price === "number" && Number.isFinite(fields.price)) {
|
|
113
|
+
product.price = Math.round(fields.price);
|
|
114
|
+
}
|
|
115
|
+
if (typeof fields.compareAtPrice === "number" && Number.isFinite(fields.compareAtPrice)) {
|
|
116
|
+
product.compareAtPrice = Math.round(fields.compareAtPrice);
|
|
117
|
+
}
|
|
118
|
+
if (typeof fields.currency === "string" && fields.currency !== "") {
|
|
119
|
+
product.currency = fields.currency;
|
|
120
|
+
}
|
|
121
|
+
const availability = fields.availability;
|
|
122
|
+
if (availability === "in_stock" || availability === "out_of_stock" || availability === "removed") {
|
|
123
|
+
product.availability = availability;
|
|
124
|
+
}
|
|
125
|
+
out.push(product);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function miss2(recipe, degraded) {
|
|
130
|
+
return { items: [], served: false, recipe, source: null, fallbackStep: null, degraded };
|
|
131
|
+
}
|
|
132
|
+
function createRecsClient(options) {
|
|
133
|
+
const { sdkKey, getDatafile, getTracker, logger } = options;
|
|
134
|
+
const requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT2;
|
|
135
|
+
function resolveSeeds(context) {
|
|
136
|
+
const clip = (s) => s.slice(0, MAX_SEED_CHARS);
|
|
137
|
+
if (Array.isArray(context?.seeds)) {
|
|
138
|
+
const seeds = context.seeds.filter((s) => typeof s === "string" && s !== "").slice(0, MAX_SEEDS).map(clip);
|
|
139
|
+
if (seeds.length > 0) return seeds;
|
|
140
|
+
}
|
|
141
|
+
if (typeof context?.seed === "string" && context.seed !== "") return [clip(context.seed)];
|
|
142
|
+
return [GLOBAL_SEED];
|
|
143
|
+
}
|
|
144
|
+
async function get(req) {
|
|
145
|
+
try {
|
|
146
|
+
const recipeHandle = typeof req?.recipe === "string" ? req.recipe : "";
|
|
147
|
+
if (recipeHandle === "") {
|
|
148
|
+
logger.warn("recs.get called with an empty recipe \u2014 returning served:false");
|
|
149
|
+
return miss2("", false);
|
|
150
|
+
}
|
|
151
|
+
const datafile = getDatafile();
|
|
152
|
+
if (!datafile) {
|
|
153
|
+
logger.debug(`SDK not ready, returning served:false for recipe "${recipeHandle}"`);
|
|
154
|
+
return miss2(recipeHandle, false);
|
|
155
|
+
}
|
|
156
|
+
const recipes = datafile.recipes;
|
|
157
|
+
const entry = recipes?.find((r) => r.recipe === recipeHandle) ?? null;
|
|
158
|
+
if (!entry) {
|
|
159
|
+
logger.debug(`Recipe "${recipeHandle}" not present in datafile \u2014 returning served:false`);
|
|
160
|
+
return miss2(recipeHandle, false);
|
|
161
|
+
}
|
|
162
|
+
const seeds = resolveSeeds(req.context);
|
|
163
|
+
const maxItems = clampMaxItems(req.maxItems);
|
|
164
|
+
const excludeOutOfStock = typeof req.excludeOutOfStock === "boolean" ? req.excludeOutOfStock : entry.excludeOutOfStock;
|
|
165
|
+
const body = {
|
|
166
|
+
snippetKey: sdkKey,
|
|
167
|
+
assigned: 0,
|
|
168
|
+
sources: [{ chain: entry.chain }],
|
|
169
|
+
maxItems,
|
|
170
|
+
excludeOutOfStock,
|
|
171
|
+
merch: entry.merch ?? [],
|
|
172
|
+
sessionAffinity: entry.sessionAffinity
|
|
173
|
+
};
|
|
174
|
+
if (seeds.length === 1) body.seedKey = seeds[0];
|
|
175
|
+
else body.seedKeys = seeds;
|
|
176
|
+
const base = datafile.collectEndpoint.replace(/\/+$/, "").replace("/v1/collect/batch", "");
|
|
177
|
+
const res = await fetch(`${base}/v1/recs/resolve`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: JSON.stringify(body),
|
|
181
|
+
signal: AbortSignal.timeout(requestTimeout)
|
|
182
|
+
});
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
logger.warn("Recs resolve failed", { recipe: recipeHandle, status: res.status });
|
|
185
|
+
return miss2(recipeHandle, true);
|
|
186
|
+
}
|
|
187
|
+
const parsed = await res.json();
|
|
188
|
+
const data = parsed?.data;
|
|
189
|
+
if (!data || typeof data !== "object") return miss2(recipeHandle, true);
|
|
190
|
+
const items = toRecProducts(Array.isArray(data.items) ? data.items : [], maxItems);
|
|
191
|
+
const servedStep = data.served ?? null;
|
|
192
|
+
const served = servedStep !== null && items.length > 0;
|
|
193
|
+
if (req.attribution) {
|
|
194
|
+
const tracker = getTracker();
|
|
195
|
+
if (tracker) {
|
|
196
|
+
tracker.queue({
|
|
197
|
+
visitorId: req.attribution.visitorId,
|
|
198
|
+
experimentId: req.attribution.experimentId,
|
|
199
|
+
variationId: req.attribution.variationId,
|
|
200
|
+
eventName: "rec:impression",
|
|
201
|
+
revenue: null,
|
|
202
|
+
timestamp: Date.now(),
|
|
203
|
+
pageUrl: "",
|
|
204
|
+
attributes: {
|
|
205
|
+
recipe: entry.recipe,
|
|
206
|
+
seed: joinIds(seeds),
|
|
207
|
+
slug: servedStep?.slug ?? "",
|
|
208
|
+
version: servedStep ? String(servedStep.version) : "",
|
|
209
|
+
step: servedStep ? String(servedStep.step) : "",
|
|
210
|
+
served: served ? "true" : "false",
|
|
211
|
+
items: joinIds(items.map((i) => i.id))
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
logger.warn("recs.get attribution supplied before tracker is ready; impression dropped");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
items,
|
|
220
|
+
served,
|
|
221
|
+
recipe: entry.recipe,
|
|
222
|
+
source: servedStep?.slug ?? null,
|
|
223
|
+
fallbackStep: servedStep?.step ?? null,
|
|
224
|
+
degraded: false
|
|
225
|
+
};
|
|
226
|
+
} catch (e) {
|
|
227
|
+
logger.warn("Recs resolve error", {
|
|
228
|
+
recipe: typeof req?.recipe === "string" ? req.recipe : "",
|
|
229
|
+
error: String(e)
|
|
230
|
+
});
|
|
231
|
+
return miss2(typeof req?.recipe === "string" ? req.recipe : "", true);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function trackClick(productId, opts) {
|
|
235
|
+
if (typeof productId !== "string" || productId === "") {
|
|
236
|
+
logger.warn("recs.trackClick called with an empty productId \u2014 dropped");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!opts || !opts.visitorId || !opts.experimentId || !opts.variationId) {
|
|
240
|
+
logger.warn("recs.trackClick requires visitorId/experimentId/variationId \u2014 dropped");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const tracker = getTracker();
|
|
244
|
+
if (!tracker) {
|
|
245
|
+
logger.warn("recs.trackClick called before tracker is ready; click dropped");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const position = typeof opts.position === "number" && Number.isInteger(opts.position) && opts.position >= 1 ? String(opts.position) : "";
|
|
249
|
+
tracker.queue({
|
|
250
|
+
visitorId: opts.visitorId,
|
|
251
|
+
experimentId: opts.experimentId,
|
|
252
|
+
variationId: opts.variationId,
|
|
253
|
+
eventName: "rec:click",
|
|
254
|
+
revenue: null,
|
|
255
|
+
timestamp: Date.now(),
|
|
256
|
+
pageUrl: "",
|
|
257
|
+
attributes: {
|
|
258
|
+
recipe: typeof opts.recipe === "string" ? opts.recipe : "",
|
|
259
|
+
item: productId.slice(0, MAX_ID_CHARS),
|
|
260
|
+
position
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return { get, trackClick };
|
|
265
|
+
}
|
|
266
|
+
|
|
16
267
|
// src/decisionLog.ts
|
|
17
268
|
function createDecisionLog() {
|
|
18
269
|
const entries = [];
|
|
19
270
|
return { entries };
|
|
20
271
|
}
|
|
21
272
|
|
|
273
|
+
// src/serverTracker.ts
|
|
274
|
+
import { randomUUID } from "crypto";
|
|
275
|
+
|
|
22
276
|
// src/reconnectBackoff.ts
|
|
23
277
|
var BASE_DELAY_MS = 1e3;
|
|
24
278
|
var MAX_DELAY_MS = 6e4;
|
|
@@ -47,6 +301,12 @@ function parseRetryAfter(headerValue) {
|
|
|
47
301
|
}
|
|
48
302
|
|
|
49
303
|
// src/serverTracker.ts
|
|
304
|
+
function stampExposure(event) {
|
|
305
|
+
return event.eventId ? event : { ...event, eventId: randomUUID() };
|
|
306
|
+
}
|
|
307
|
+
function stampMetric(event) {
|
|
308
|
+
return event.eventId ? event : { ...event, eventId: randomUUID() };
|
|
309
|
+
}
|
|
50
310
|
function createServerTracker(options) {
|
|
51
311
|
const {
|
|
52
312
|
sdkKey,
|
|
@@ -59,6 +319,7 @@ function createServerTracker(options) {
|
|
|
59
319
|
} = options;
|
|
60
320
|
const batchEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/batch");
|
|
61
321
|
const metricEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/metric-batch");
|
|
322
|
+
const ordersEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/orders");
|
|
62
323
|
let exposureQueue = [];
|
|
63
324
|
let metricQueue = [];
|
|
64
325
|
let flushTimer = null;
|
|
@@ -70,7 +331,7 @@ function createServerTracker(options) {
|
|
|
70
331
|
}, flushInterval);
|
|
71
332
|
function queue(event) {
|
|
72
333
|
if (destroyed) return;
|
|
73
|
-
exposureQueue.push(event);
|
|
334
|
+
exposureQueue.push(stampExposure(event));
|
|
74
335
|
if (exposureQueue.length > maxQueueSize) {
|
|
75
336
|
const dropped = exposureQueue.length - maxQueueSize;
|
|
76
337
|
exposureQueue = exposureQueue.slice(-maxQueueSize);
|
|
@@ -84,7 +345,7 @@ function createServerTracker(options) {
|
|
|
84
345
|
}
|
|
85
346
|
function queueMetric(event) {
|
|
86
347
|
if (destroyed) return;
|
|
87
|
-
metricQueue.push(event);
|
|
348
|
+
metricQueue.push(stampMetric(event));
|
|
88
349
|
if (metricQueue.length > maxQueueSize) {
|
|
89
350
|
const dropped = metricQueue.length - maxQueueSize;
|
|
90
351
|
metricQueue = metricQueue.slice(-maxQueueSize);
|
|
@@ -146,6 +407,47 @@ function createServerTracker(options) {
|
|
|
146
407
|
}
|
|
147
408
|
}
|
|
148
409
|
}
|
|
410
|
+
async function trackPurchase(visitorId, order) {
|
|
411
|
+
if (destroyed) return;
|
|
412
|
+
const payload = {
|
|
413
|
+
...order,
|
|
414
|
+
eventId: randomUUID(),
|
|
415
|
+
visitorId,
|
|
416
|
+
timestamp: Date.now(),
|
|
417
|
+
pageUrl: ""
|
|
418
|
+
};
|
|
419
|
+
const body = JSON.stringify({ sdkKey, orders: [payload] });
|
|
420
|
+
let backoff = resetBackoff();
|
|
421
|
+
let pendingRetryAfterMs = void 0;
|
|
422
|
+
for (let attempt = 0; attempt <= retryAttempts; attempt++) {
|
|
423
|
+
try {
|
|
424
|
+
const response = await fetch(ordersEndpoint, {
|
|
425
|
+
method: "POST",
|
|
426
|
+
headers: { "Content-Type": "application/json" },
|
|
427
|
+
body,
|
|
428
|
+
signal: AbortSignal.timeout(1e4)
|
|
429
|
+
});
|
|
430
|
+
if (!response.ok) {
|
|
431
|
+
const parsed = parseRetryAfter(response.headers.get("retry-after"));
|
|
432
|
+
if (parsed !== null) pendingRetryAfterMs = parsed;
|
|
433
|
+
throw new Error(`HTTP ${response.status}`);
|
|
434
|
+
}
|
|
435
|
+
logger.debug(`Purchase order ${order.orderId} sent for visitor ${visitorId}`);
|
|
436
|
+
return;
|
|
437
|
+
} catch (e) {
|
|
438
|
+
if (attempt < retryAttempts) {
|
|
439
|
+
backoff = nextBackoff(backoff, pendingRetryAfterMs);
|
|
440
|
+
pendingRetryAfterMs = void 0;
|
|
441
|
+
await new Promise((r) => setTimeout(r, backoff.delayMs));
|
|
442
|
+
} else {
|
|
443
|
+
logger.error(
|
|
444
|
+
`Failed to send purchase order ${order.orderId} after ${retryAttempts} retries`,
|
|
445
|
+
{ endpoint: ordersEndpoint, error: String(e) }
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
149
451
|
async function destroy() {
|
|
150
452
|
destroyed = true;
|
|
151
453
|
if (flushTimer) {
|
|
@@ -154,186 +456,17 @@ function createServerTracker(options) {
|
|
|
154
456
|
}
|
|
155
457
|
await flush();
|
|
156
458
|
}
|
|
157
|
-
return { queue, queueMetric, flush, destroy };
|
|
459
|
+
return { queue, queueMetric, trackPurchase, flush, destroy };
|
|
158
460
|
}
|
|
159
461
|
|
|
160
462
|
// 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
|
-
}
|
|
463
|
+
import {
|
|
464
|
+
createStreamingClient,
|
|
465
|
+
createBrowserStreamingClient
|
|
466
|
+
} from "@avsbhq/utils/streaming";
|
|
334
467
|
|
|
335
468
|
// src/version.ts
|
|
336
|
-
var SDK_VERSION = "0.
|
|
469
|
+
var SDK_VERSION = "0.4.0";
|
|
337
470
|
|
|
338
471
|
// src/UserBoundClient.ts
|
|
339
472
|
function createUserBoundClient(server, context) {
|
|
@@ -509,6 +642,42 @@ var AvsbServer = class {
|
|
|
509
642
|
}
|
|
510
643
|
return this._utils;
|
|
511
644
|
}
|
|
645
|
+
/**
|
|
646
|
+
* Server-side datasets read surface (e-commerce suite Phase 2).
|
|
647
|
+
* Constructed lazily on first access, mirroring the `utils` getter.
|
|
648
|
+
* `client.datasets.get(slug, key)` resolves the live version from the
|
|
649
|
+
* current datafile and never throws — see ./datasets.ts.
|
|
650
|
+
*/
|
|
651
|
+
_datasets = null;
|
|
652
|
+
get datasets() {
|
|
653
|
+
if (!this._datasets) {
|
|
654
|
+
this._datasets = createDatasetsClient({
|
|
655
|
+
sdkKey: this.sdkKey,
|
|
656
|
+
getDatafile: () => this.datafile,
|
|
657
|
+
logger: this.logger
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
return this._datasets;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Server-side recommendations surface (e-commerce suite overhaul, C3).
|
|
664
|
+
* Constructed lazily on first access, mirroring the `datasets` getter.
|
|
665
|
+
* `client.recs.get({ recipe, … })` resolves the recipe registry from the
|
|
666
|
+
* current datafile and never throws; impressions/clicks ride the server
|
|
667
|
+
* tracker's queue and revenue attaches via the existing `trackPurchase`.
|
|
668
|
+
*/
|
|
669
|
+
_recs = null;
|
|
670
|
+
get recs() {
|
|
671
|
+
if (!this._recs) {
|
|
672
|
+
this._recs = createRecsClient({
|
|
673
|
+
sdkKey: this.sdkKey,
|
|
674
|
+
getDatafile: () => this.datafile,
|
|
675
|
+
getTracker: () => this.tracker,
|
|
676
|
+
logger: this.logger
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return this._recs;
|
|
680
|
+
}
|
|
512
681
|
constructor(options) {
|
|
513
682
|
this.sdkKey = options.sdkKey;
|
|
514
683
|
this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
|
|
@@ -693,6 +862,16 @@ var AvsbServer = class {
|
|
|
693
862
|
return out;
|
|
694
863
|
}
|
|
695
864
|
// ---------------------------------------------------------------------------
|
|
865
|
+
// Purchase tracking — fires immediately to /v1/collect/orders
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
async trackPurchase(visitorId, order) {
|
|
868
|
+
if (!this.tracker) {
|
|
869
|
+
this.logger.warn("trackPurchase called before tracker is ready; order dropped");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
return this.tracker.trackPurchase(visitorId, order);
|
|
873
|
+
}
|
|
874
|
+
// ---------------------------------------------------------------------------
|
|
696
875
|
// §5.6 Unified track — context required
|
|
697
876
|
// ---------------------------------------------------------------------------
|
|
698
877
|
track(eventKey, payload) {
|
package/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avsbhq/node",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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.3.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
|
+
}
|