@avsbhq/node 1.6.0 → 1.6.2
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 +14 -14
- package/dist/index.cjs +226 -8
- package/dist/index.d.cts +116 -7
- package/dist/index.d.ts +116 -7
- package/dist/index.js +226 -8
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ Get the SDK key for the environment you want to target from your A vs B project'
|
|
|
55
55
|
AVSB_SDK_KEY=sdk_production_...
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
When used server-side, the SDK key only ever needs to leave the server to hit the datafile CDN
|
|
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
|
|
|
@@ -63,7 +63,7 @@ Server SDK keys cannot be rotated without a process restart; the new key must be
|
|
|
63
63
|
|
|
64
64
|
## 4. Identity
|
|
65
65
|
|
|
66
|
-
The server SDK is stateless per request
|
|
66
|
+
The server SDK is stateless per request; it does not hold a persistent context. Pass the user context on each call using `forUser()` or as a `context` option on individual calls.
|
|
67
67
|
|
|
68
68
|
### `forUser(context)` / `createUserContext(context)`
|
|
69
69
|
|
|
@@ -106,7 +106,7 @@ const flag = server.getBoolFlag('big-cart-banner', false, {
|
|
|
106
106
|
viewedSkus: ['SKU-1', 'SKU-2'], // for "products viewed" matching on SKU
|
|
107
107
|
viewedCategories: ['trainers'], // for "products viewed" matching on category
|
|
108
108
|
purchaseHistory: {
|
|
109
|
-
// PurchaseHistoryAttribute
|
|
109
|
+
// PurchaseHistoryAttribute, exported for your own declarations
|
|
110
110
|
hasPurchased: true,
|
|
111
111
|
lastPurchaseAt: 1765500000000, // epoch milliseconds (optional)
|
|
112
112
|
lifetimeSpend: 540.0, // decimal MAJOR units (optional)
|
|
@@ -118,9 +118,9 @@ const flag = server.getBoolFlag('big-cart-banner', false, {
|
|
|
118
118
|
|
|
119
119
|
Server-side semantics to be aware of:
|
|
120
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)
|
|
122
|
-
- **Missing or mistyped attributes evaluate the condition to `false`.** A completely absent `purchaseHistory` makes ALL purchase-history operators false
|
|
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
|
|
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
124
|
|
|
125
125
|
### `alias(previousContext, newContext)`
|
|
126
126
|
|
|
@@ -135,7 +135,7 @@ await server.alias(
|
|
|
135
135
|
|
|
136
136
|
### Sharing identity with the web snippet
|
|
137
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
|
|
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
139
|
|
|
140
140
|
---
|
|
141
141
|
|
|
@@ -180,13 +180,13 @@ const flag = server.getFlag<boolean>('checkout-v2', false, {
|
|
|
180
180
|
### The `Flag<T>` object
|
|
181
181
|
|
|
182
182
|
```ts
|
|
183
|
-
flag.value // T
|
|
183
|
+
flag.value // T, the variation value
|
|
184
184
|
flag.isEnabled() // true if source === 'rule' && value is truthy
|
|
185
185
|
flag.variationKey // 'on' | 'off' | 'control' | null (null = default served)
|
|
186
186
|
flag.exists() // false when source === 'not_found'
|
|
187
187
|
flag.source // 'rule' | 'sticky' | 'holdout' | 'datafileOverride' | 'default' | ...
|
|
188
188
|
flag.ruleId // which rule matched
|
|
189
|
-
flag.reasons // string[]
|
|
189
|
+
flag.reasons // string[], evaluation path
|
|
190
190
|
```
|
|
191
191
|
|
|
192
192
|
### `getAllFlags(context, options?)`
|
|
@@ -275,7 +275,7 @@ if (!result.success && !result.degraded) {
|
|
|
275
275
|
}
|
|
276
276
|
```
|
|
277
277
|
|
|
278
|
-
`onReady()` never rejects
|
|
278
|
+
`onReady()` never rejects; it always resolves. Check `result.success` to decide whether to proceed.
|
|
279
279
|
|
|
280
280
|
### Event bus
|
|
281
281
|
|
|
@@ -382,7 +382,7 @@ app.get('/checkout', (req, res) => {
|
|
|
382
382
|
})
|
|
383
383
|
```
|
|
384
384
|
|
|
385
|
-
`expressMiddleware` decorates each request with `req.avsb
|
|
385
|
+
`expressMiddleware` decorates each request with `req.avsb`: a `UserBoundClient` scoped to the context returned by `contextFrom`. It also opens an `AsyncLocalStorage` scope so `getRequestClient()` from `@avsbhq/utils` works anywhere in the call chain.
|
|
386
386
|
|
|
387
387
|
### Fastify
|
|
388
388
|
|
|
@@ -443,7 +443,7 @@ DynamoDB and PostgreSQL adapters are available in `@avsbhq/utils`.
|
|
|
443
443
|
|
|
444
444
|
### Real-time streaming
|
|
445
445
|
|
|
446
|
-
Enable SSE streaming to receive datafile updates the instant a flag changes
|
|
446
|
+
Enable SSE streaming to receive datafile updates the instant a flag changes, with no polling delay:
|
|
447
447
|
|
|
448
448
|
```ts
|
|
449
449
|
const server = new AvsbServer({
|
|
@@ -480,7 +480,7 @@ const entries = user.getDecisionLog().entries
|
|
|
480
480
|
| `client.variation('key', context, default)` | `server.forUser(ctx).getBoolFlag('key', default).value` |
|
|
481
481
|
| `client.variationDetail('key', context, default)` | `server.forUser(ctx).getFlag('key', default)` |
|
|
482
482
|
| `client.track('event', context, { metricValue })` | `server.forUser(ctx).track('event', { value })` |
|
|
483
|
-
| `client.identify(context)` | not applicable
|
|
483
|
+
| `client.identify(context)` | not applicable (server is stateless); use `forUser(ctx)` |
|
|
484
484
|
| `client.allFlagsState(context)` | `server.getAllFlags(ctx)` |
|
|
485
485
|
| `client.close()` | `await server.close()` |
|
|
486
486
|
|
|
@@ -488,7 +488,7 @@ Key differences:
|
|
|
488
488
|
- `getFlag` returns `Flag<T>` not a raw value. Access `.value` for the primitive.
|
|
489
489
|
- `track` uses `value` (not `metricValue`) for the numeric metric.
|
|
490
490
|
- The server is context-free at construction; all evaluation is per-request via `forUser()`.
|
|
491
|
-
- Multi-context is native
|
|
491
|
+
- Multi-context is native, with no `ldUser` adapter needed.
|
|
492
492
|
|
|
493
493
|
### From Statsig Node
|
|
494
494
|
|
package/dist/index.cjs
CHANGED
|
@@ -50,7 +50,7 @@ function createDatasetsClient(options) {
|
|
|
50
50
|
const requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
|
|
51
51
|
async function get(slug, key) {
|
|
52
52
|
if (!slug || !key) {
|
|
53
|
-
logger.warn("datasets.get called with an empty slug or key
|
|
53
|
+
logger.warn("datasets.get called with an empty slug or key; returning not found");
|
|
54
54
|
return miss(slug, null);
|
|
55
55
|
}
|
|
56
56
|
const datafile = getDatafile();
|
|
@@ -60,11 +60,11 @@ function createDatasetsClient(options) {
|
|
|
60
60
|
}
|
|
61
61
|
const dataset = datafile.datasets?.find((d) => d.slug === slug);
|
|
62
62
|
if (!dataset) {
|
|
63
|
-
logger.debug(`Dataset "${slug}" not present in datafile
|
|
63
|
+
logger.debug(`Dataset "${slug}" not present in datafile; returning not found`);
|
|
64
64
|
return miss(slug, null);
|
|
65
65
|
}
|
|
66
66
|
if (!dataset.liveVersion) {
|
|
67
|
-
logger.debug(`Dataset "${slug}" has no live version
|
|
67
|
+
logger.debug(`Dataset "${slug}" has no live version; returning not found`);
|
|
68
68
|
return miss(slug, null);
|
|
69
69
|
}
|
|
70
70
|
const base = datafile.collectEndpoint.replace(/\/+$/, "");
|
|
@@ -92,6 +92,205 @@ function createDatasetsClient(options) {
|
|
|
92
92
|
return { get };
|
|
93
93
|
}
|
|
94
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; 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; 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; dropped");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!opts || !opts.visitorId || !opts.experimentId || !opts.variationId) {
|
|
267
|
+
logger.warn("recs.trackClick requires visitorId/experimentId/variationId; 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
|
+
|
|
95
294
|
// src/decisionLog.ts
|
|
96
295
|
function createDecisionLog() {
|
|
97
296
|
const entries = [];
|
|
@@ -291,7 +490,7 @@ function createServerTracker(options) {
|
|
|
291
490
|
var import_streaming = require("@avsbhq/utils/streaming");
|
|
292
491
|
|
|
293
492
|
// src/version.ts
|
|
294
|
-
var SDK_VERSION = "
|
|
493
|
+
var SDK_VERSION = "1.6.2";
|
|
295
494
|
|
|
296
495
|
// src/UserBoundClient.ts
|
|
297
496
|
function createUserBoundClient(server, context) {
|
|
@@ -438,7 +637,7 @@ var AvsbServer = class {
|
|
|
438
637
|
readyPromise = null;
|
|
439
638
|
pendingEvents = [];
|
|
440
639
|
/**
|
|
441
|
-
* §6
|
|
640
|
+
* §6: `client.utils.*` surface. Constructed lazily on first access so
|
|
442
641
|
* the utils package is only imported when actually consumed.
|
|
443
642
|
*
|
|
444
643
|
* The server's event surface (`ServerEventMap`) is a subset of the
|
|
@@ -471,7 +670,7 @@ var AvsbServer = class {
|
|
|
471
670
|
* Server-side datasets read surface (e-commerce suite Phase 2).
|
|
472
671
|
* Constructed lazily on first access, mirroring the `utils` getter.
|
|
473
672
|
* `client.datasets.get(slug, key)` resolves the live version from the
|
|
474
|
-
* current datafile and never throws
|
|
673
|
+
* current datafile and never throws; see ./datasets.ts.
|
|
475
674
|
*/
|
|
476
675
|
_datasets = null;
|
|
477
676
|
get datasets() {
|
|
@@ -484,6 +683,25 @@ var AvsbServer = class {
|
|
|
484
683
|
}
|
|
485
684
|
return this._datasets;
|
|
486
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
|
+
}
|
|
487
705
|
constructor(options) {
|
|
488
706
|
this.sdkKey = options.sdkKey;
|
|
489
707
|
this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
|
|
@@ -606,7 +824,7 @@ var AvsbServer = class {
|
|
|
606
824
|
const decisionLog = options?.decisionLog;
|
|
607
825
|
if (!context) {
|
|
608
826
|
this.logger.warn(
|
|
609
|
-
`getFlag called without context for flagKey="${flagKey}"
|
|
827
|
+
`getFlag called without context for flagKey="${flagKey}"; returning default`
|
|
610
828
|
);
|
|
611
829
|
return (0, import_core2.notFoundFlag)(defaultValue, "no context provided");
|
|
612
830
|
}
|
|
@@ -868,7 +1086,7 @@ var AvsbServer = class {
|
|
|
868
1086
|
const mismatch = (0, import_core2.checkExpectedType)(datafileFlag, expectedType, flag.value);
|
|
869
1087
|
if (mismatch) {
|
|
870
1088
|
this.logger.warn(
|
|
871
|
-
`Type mismatch on flag "${flagKey}": ${mismatch}
|
|
1089
|
+
`Type mismatch on flag "${flagKey}": ${mismatch}; returning default`
|
|
872
1090
|
);
|
|
873
1091
|
return (0, import_core2.notFoundFlag)(defaultValue, mismatch);
|
|
874
1092
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FlagDatafile, StickyBucketService, Logger, EvaluationSource, StickyAssignment, DecideOption, Flag, TrackPayload, DecisionLog, InitResult, EvalContext } from '@avsbhq/core';
|
|
2
|
-
export { DatafileDataset, DatafileDatasetType, 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';
|
|
2
|
+
export { DatafileDataset, DatafileDatasetType, DecideOption, DecisionLog, DecisionLogEntry, EvalContext, EvaluationSource, ExposureEvent, Flag, FlagDatafile, FlagEvaluationDetails, InitResult, Logger, MetricEvent, MultiContext, PurchaseHistoryAttribute, 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
5
|
export { StreamingClient, StreamingOptions, createStreamingClient } from '@avsbhq/utils/streaming';
|
|
@@ -66,7 +66,7 @@ interface MiddlewareConfig {
|
|
|
66
66
|
/**
|
|
67
67
|
* Purchase order shape for server-side purchase tracking.
|
|
68
68
|
* Mirrors the client-side snippet PurchaseOrder interface.
|
|
69
|
-
* Decimal values are forwarded as-is
|
|
69
|
+
* Decimal values are forwarded as-is; no server-side currency conversion.
|
|
70
70
|
*/
|
|
71
71
|
interface PurchaseOrder {
|
|
72
72
|
/** Required. Unique order/transaction identifier. */
|
|
@@ -80,8 +80,19 @@ interface PurchaseOrder {
|
|
|
80
80
|
tax?: number;
|
|
81
81
|
discount?: number;
|
|
82
82
|
coupon?: string;
|
|
83
|
+
/** Mark this as a test purchase. Test orders are stored but excluded from all
|
|
84
|
+
* revenue, profit, recommendation, and audience calculations. Only send when
|
|
85
|
+
* intentionally testing your integration. */
|
|
86
|
+
test?: boolean;
|
|
83
87
|
items?: Array<{
|
|
84
88
|
sku: string;
|
|
89
|
+
/**
|
|
90
|
+
* Canonical product-grain key (e-commerce suite overhaul, contract C1).
|
|
91
|
+
* Pass when known so order items join catalog/recommendation analytics
|
|
92
|
+
* without alias resolution; the ingestion worker derives it from `sku`
|
|
93
|
+
* otherwise.
|
|
94
|
+
*/
|
|
95
|
+
productKey?: string;
|
|
85
96
|
name?: string;
|
|
86
97
|
price?: number;
|
|
87
98
|
quantity?: number;
|
|
@@ -124,6 +135,95 @@ interface DatasetsClient {
|
|
|
124
135
|
get(slug: string, key: string): Promise<DatasetGetResult>;
|
|
125
136
|
}
|
|
126
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Server-side recommendations client (e-commerce suite overhaul, contract C3).
|
|
140
|
+
*
|
|
141
|
+
* Mirrors ./datasets.ts: resolves a recipe handle against the datafile's
|
|
142
|
+
* recipe registry (`datafile.recipes`, additive) and POSTs the standard
|
|
143
|
+
* resolve body to the edge worker:
|
|
144
|
+
*
|
|
145
|
+
* POST {ingestionHost}/v1/recs/resolve
|
|
146
|
+
*
|
|
147
|
+
* The ingestion host is derived from the datafile's `collectEndpoint`. The
|
|
148
|
+
* worker's key cache resolves SDK keys as well as snippet keys, so the body's
|
|
149
|
+
* key field simply carries the SDK key.
|
|
150
|
+
*
|
|
151
|
+
* Attribution is OPT-IN server-side: `get()` fires a `rec:impression` (via
|
|
152
|
+
* the server tracker) only when the caller passes `attribution`; a server
|
|
153
|
+
* has no ambient variation context the way a browser harness does.
|
|
154
|
+
*
|
|
155
|
+
* Error posture matches datasets/flag evaluation: this client NEVER throws.
|
|
156
|
+
* Unknown recipe, SDK not ready, HTTP errors, timeouts and network failures
|
|
157
|
+
* all degrade to `{ items: [], served: false }`.
|
|
158
|
+
*/
|
|
159
|
+
interface RecProduct {
|
|
160
|
+
id: string;
|
|
161
|
+
title?: string;
|
|
162
|
+
image?: string;
|
|
163
|
+
href?: string;
|
|
164
|
+
category?: string;
|
|
165
|
+
/** MINOR units (ISO-4217). */
|
|
166
|
+
price?: number;
|
|
167
|
+
/** MINOR units. */
|
|
168
|
+
compareAtPrice?: number;
|
|
169
|
+
currency?: string;
|
|
170
|
+
availability?: 'in_stock' | 'out_of_stock' | 'removed';
|
|
171
|
+
}
|
|
172
|
+
interface RecsAttribution {
|
|
173
|
+
visitorId: string;
|
|
174
|
+
experimentId: string;
|
|
175
|
+
variationId: string;
|
|
176
|
+
}
|
|
177
|
+
interface RecsGetRequest {
|
|
178
|
+
recipe: string;
|
|
179
|
+
context?: {
|
|
180
|
+
/** Single seed product key (product page). */
|
|
181
|
+
seed?: string;
|
|
182
|
+
/** Up to 3 seed product keys (cart page). */
|
|
183
|
+
seeds?: string[];
|
|
184
|
+
};
|
|
185
|
+
maxItems?: number;
|
|
186
|
+
excludeOutOfStock?: boolean;
|
|
187
|
+
/** When present, a rec:impression is queued with these ids on success. */
|
|
188
|
+
attribution?: RecsAttribution;
|
|
189
|
+
}
|
|
190
|
+
interface RecsResult {
|
|
191
|
+
items: RecProduct[];
|
|
192
|
+
served: boolean;
|
|
193
|
+
recipe: string;
|
|
194
|
+
/** Slug of the chain step that served, null on a miss. */
|
|
195
|
+
source: string | null;
|
|
196
|
+
fallbackStep: number | null;
|
|
197
|
+
/** True when the resolve failed (timeout / network / HTTP / parse). */
|
|
198
|
+
degraded: boolean;
|
|
199
|
+
}
|
|
200
|
+
interface RecsClient {
|
|
201
|
+
/** Resolve items for a recipe. Never throws. */
|
|
202
|
+
get(req: RecsGetRequest): Promise<RecsResult>;
|
|
203
|
+
/**
|
|
204
|
+
* Queue a rec:click for a product the shopper clicked. Pass `recipe` when
|
|
205
|
+
* known: results group click-through by (variation, recipe).
|
|
206
|
+
*/
|
|
207
|
+
trackClick(productId: string, opts: RecsAttribution & {
|
|
208
|
+
position?: number;
|
|
209
|
+
recipe?: string;
|
|
210
|
+
}): void;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* One entry of the datafile recipe registry (contract C3). Additive on
|
|
214
|
+
* FlagDatafile; SDKs on older datafiles see it absent.
|
|
215
|
+
*/
|
|
216
|
+
interface DatafileRecipe {
|
|
217
|
+
recipe: string;
|
|
218
|
+
chain: Array<{
|
|
219
|
+
slug: string;
|
|
220
|
+
version: number;
|
|
221
|
+
}>;
|
|
222
|
+
excludeOutOfStock: boolean;
|
|
223
|
+
sessionAffinity: boolean;
|
|
224
|
+
merch?: unknown[];
|
|
225
|
+
}
|
|
226
|
+
|
|
127
227
|
/**
|
|
128
228
|
* A per-request scoped client with a bound EvalContext.
|
|
129
229
|
*
|
|
@@ -131,7 +231,7 @@ interface DatasetsClient {
|
|
|
131
231
|
* alias. All flag evaluations and track calls use the bound context.
|
|
132
232
|
*
|
|
133
233
|
* Forced decisions set on a UserBoundClient survive only for the lifetime
|
|
134
|
-
* of the instance
|
|
234
|
+
* of the instance; they do not affect the shared AvsbServer override state.
|
|
135
235
|
* This makes them safe for test middleware and per-request debugging.
|
|
136
236
|
*
|
|
137
237
|
* Spec §5.8.
|
|
@@ -195,7 +295,7 @@ declare class AvsbServer {
|
|
|
195
295
|
private readyPromise;
|
|
196
296
|
private pendingEvents;
|
|
197
297
|
/**
|
|
198
|
-
* §6
|
|
298
|
+
* §6: `client.utils.*` surface. Constructed lazily on first access so
|
|
199
299
|
* the utils package is only imported when actually consumed.
|
|
200
300
|
*
|
|
201
301
|
* The server's event surface (`ServerEventMap`) is a subset of the
|
|
@@ -209,10 +309,19 @@ declare class AvsbServer {
|
|
|
209
309
|
* Server-side datasets read surface (e-commerce suite Phase 2).
|
|
210
310
|
* Constructed lazily on first access, mirroring the `utils` getter.
|
|
211
311
|
* `client.datasets.get(slug, key)` resolves the live version from the
|
|
212
|
-
* current datafile and never throws
|
|
312
|
+
* current datafile and never throws; see ./datasets.ts.
|
|
213
313
|
*/
|
|
214
314
|
private _datasets;
|
|
215
315
|
get datasets(): DatasetsClient;
|
|
316
|
+
/**
|
|
317
|
+
* Server-side recommendations surface (e-commerce suite overhaul, C3).
|
|
318
|
+
* Constructed lazily on first access, mirroring the `datasets` getter.
|
|
319
|
+
* `client.recs.get({ recipe, … })` resolves the recipe registry from the
|
|
320
|
+
* current datafile and never throws; impressions/clicks ride the server
|
|
321
|
+
* tracker's queue and revenue attaches via the existing `trackPurchase`.
|
|
322
|
+
*/
|
|
323
|
+
private _recs;
|
|
324
|
+
get recs(): RecsClient;
|
|
216
325
|
constructor(options: AvsbServerOptions);
|
|
217
326
|
onReady(): Promise<InitResult>;
|
|
218
327
|
private initializeAndReady;
|
|
@@ -303,6 +412,6 @@ declare class RedisStickyBucketService implements StickyBucketService {
|
|
|
303
412
|
|
|
304
413
|
declare function createDecisionLog(): DecisionLog;
|
|
305
414
|
|
|
306
|
-
declare const SDK_VERSION = "
|
|
415
|
+
declare const SDK_VERSION = "1.6.2";
|
|
307
416
|
|
|
308
|
-
export { AvsbServer, type AvsbServerOptions, type DatasetGetResult, type DatasetsClient, type EventOptions, type GetAllFlagsOptions, type GetFlagOptions, InMemoryStickyBucketService, type MiddlewareConfig, type PurchaseOrder, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type UserBoundClient, createDecisionLog, expressMiddleware, fastifyPlugin };
|
|
417
|
+
export { AvsbServer, type AvsbServerOptions, type DatafileRecipe, type DatasetGetResult, type DatasetsClient, type EventOptions, type GetAllFlagsOptions, type GetFlagOptions, InMemoryStickyBucketService, type MiddlewareConfig, type PurchaseOrder, type RecProduct, type RecsAttribution, type RecsClient, type RecsGetRequest, type RecsResult, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type UserBoundClient, createDecisionLog, expressMiddleware, fastifyPlugin };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FlagDatafile, StickyBucketService, Logger, EvaluationSource, StickyAssignment, DecideOption, Flag, TrackPayload, DecisionLog, InitResult, EvalContext } from '@avsbhq/core';
|
|
2
|
-
export { DatafileDataset, DatafileDatasetType, 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';
|
|
2
|
+
export { DatafileDataset, DatafileDatasetType, DecideOption, DecisionLog, DecisionLogEntry, EvalContext, EvaluationSource, ExposureEvent, Flag, FlagDatafile, FlagEvaluationDetails, InitResult, Logger, MetricEvent, MultiContext, PurchaseHistoryAttribute, 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
5
|
export { StreamingClient, StreamingOptions, createStreamingClient } from '@avsbhq/utils/streaming';
|
|
@@ -66,7 +66,7 @@ interface MiddlewareConfig {
|
|
|
66
66
|
/**
|
|
67
67
|
* Purchase order shape for server-side purchase tracking.
|
|
68
68
|
* Mirrors the client-side snippet PurchaseOrder interface.
|
|
69
|
-
* Decimal values are forwarded as-is
|
|
69
|
+
* Decimal values are forwarded as-is; no server-side currency conversion.
|
|
70
70
|
*/
|
|
71
71
|
interface PurchaseOrder {
|
|
72
72
|
/** Required. Unique order/transaction identifier. */
|
|
@@ -80,8 +80,19 @@ interface PurchaseOrder {
|
|
|
80
80
|
tax?: number;
|
|
81
81
|
discount?: number;
|
|
82
82
|
coupon?: string;
|
|
83
|
+
/** Mark this as a test purchase. Test orders are stored but excluded from all
|
|
84
|
+
* revenue, profit, recommendation, and audience calculations. Only send when
|
|
85
|
+
* intentionally testing your integration. */
|
|
86
|
+
test?: boolean;
|
|
83
87
|
items?: Array<{
|
|
84
88
|
sku: string;
|
|
89
|
+
/**
|
|
90
|
+
* Canonical product-grain key (e-commerce suite overhaul, contract C1).
|
|
91
|
+
* Pass when known so order items join catalog/recommendation analytics
|
|
92
|
+
* without alias resolution; the ingestion worker derives it from `sku`
|
|
93
|
+
* otherwise.
|
|
94
|
+
*/
|
|
95
|
+
productKey?: string;
|
|
85
96
|
name?: string;
|
|
86
97
|
price?: number;
|
|
87
98
|
quantity?: number;
|
|
@@ -124,6 +135,95 @@ interface DatasetsClient {
|
|
|
124
135
|
get(slug: string, key: string): Promise<DatasetGetResult>;
|
|
125
136
|
}
|
|
126
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Server-side recommendations client (e-commerce suite overhaul, contract C3).
|
|
140
|
+
*
|
|
141
|
+
* Mirrors ./datasets.ts: resolves a recipe handle against the datafile's
|
|
142
|
+
* recipe registry (`datafile.recipes`, additive) and POSTs the standard
|
|
143
|
+
* resolve body to the edge worker:
|
|
144
|
+
*
|
|
145
|
+
* POST {ingestionHost}/v1/recs/resolve
|
|
146
|
+
*
|
|
147
|
+
* The ingestion host is derived from the datafile's `collectEndpoint`. The
|
|
148
|
+
* worker's key cache resolves SDK keys as well as snippet keys, so the body's
|
|
149
|
+
* key field simply carries the SDK key.
|
|
150
|
+
*
|
|
151
|
+
* Attribution is OPT-IN server-side: `get()` fires a `rec:impression` (via
|
|
152
|
+
* the server tracker) only when the caller passes `attribution`; a server
|
|
153
|
+
* has no ambient variation context the way a browser harness does.
|
|
154
|
+
*
|
|
155
|
+
* Error posture matches datasets/flag evaluation: this client NEVER throws.
|
|
156
|
+
* Unknown recipe, SDK not ready, HTTP errors, timeouts and network failures
|
|
157
|
+
* all degrade to `{ items: [], served: false }`.
|
|
158
|
+
*/
|
|
159
|
+
interface RecProduct {
|
|
160
|
+
id: string;
|
|
161
|
+
title?: string;
|
|
162
|
+
image?: string;
|
|
163
|
+
href?: string;
|
|
164
|
+
category?: string;
|
|
165
|
+
/** MINOR units (ISO-4217). */
|
|
166
|
+
price?: number;
|
|
167
|
+
/** MINOR units. */
|
|
168
|
+
compareAtPrice?: number;
|
|
169
|
+
currency?: string;
|
|
170
|
+
availability?: 'in_stock' | 'out_of_stock' | 'removed';
|
|
171
|
+
}
|
|
172
|
+
interface RecsAttribution {
|
|
173
|
+
visitorId: string;
|
|
174
|
+
experimentId: string;
|
|
175
|
+
variationId: string;
|
|
176
|
+
}
|
|
177
|
+
interface RecsGetRequest {
|
|
178
|
+
recipe: string;
|
|
179
|
+
context?: {
|
|
180
|
+
/** Single seed product key (product page). */
|
|
181
|
+
seed?: string;
|
|
182
|
+
/** Up to 3 seed product keys (cart page). */
|
|
183
|
+
seeds?: string[];
|
|
184
|
+
};
|
|
185
|
+
maxItems?: number;
|
|
186
|
+
excludeOutOfStock?: boolean;
|
|
187
|
+
/** When present, a rec:impression is queued with these ids on success. */
|
|
188
|
+
attribution?: RecsAttribution;
|
|
189
|
+
}
|
|
190
|
+
interface RecsResult {
|
|
191
|
+
items: RecProduct[];
|
|
192
|
+
served: boolean;
|
|
193
|
+
recipe: string;
|
|
194
|
+
/** Slug of the chain step that served, null on a miss. */
|
|
195
|
+
source: string | null;
|
|
196
|
+
fallbackStep: number | null;
|
|
197
|
+
/** True when the resolve failed (timeout / network / HTTP / parse). */
|
|
198
|
+
degraded: boolean;
|
|
199
|
+
}
|
|
200
|
+
interface RecsClient {
|
|
201
|
+
/** Resolve items for a recipe. Never throws. */
|
|
202
|
+
get(req: RecsGetRequest): Promise<RecsResult>;
|
|
203
|
+
/**
|
|
204
|
+
* Queue a rec:click for a product the shopper clicked. Pass `recipe` when
|
|
205
|
+
* known: results group click-through by (variation, recipe).
|
|
206
|
+
*/
|
|
207
|
+
trackClick(productId: string, opts: RecsAttribution & {
|
|
208
|
+
position?: number;
|
|
209
|
+
recipe?: string;
|
|
210
|
+
}): void;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* One entry of the datafile recipe registry (contract C3). Additive on
|
|
214
|
+
* FlagDatafile; SDKs on older datafiles see it absent.
|
|
215
|
+
*/
|
|
216
|
+
interface DatafileRecipe {
|
|
217
|
+
recipe: string;
|
|
218
|
+
chain: Array<{
|
|
219
|
+
slug: string;
|
|
220
|
+
version: number;
|
|
221
|
+
}>;
|
|
222
|
+
excludeOutOfStock: boolean;
|
|
223
|
+
sessionAffinity: boolean;
|
|
224
|
+
merch?: unknown[];
|
|
225
|
+
}
|
|
226
|
+
|
|
127
227
|
/**
|
|
128
228
|
* A per-request scoped client with a bound EvalContext.
|
|
129
229
|
*
|
|
@@ -131,7 +231,7 @@ interface DatasetsClient {
|
|
|
131
231
|
* alias. All flag evaluations and track calls use the bound context.
|
|
132
232
|
*
|
|
133
233
|
* Forced decisions set on a UserBoundClient survive only for the lifetime
|
|
134
|
-
* of the instance
|
|
234
|
+
* of the instance; they do not affect the shared AvsbServer override state.
|
|
135
235
|
* This makes them safe for test middleware and per-request debugging.
|
|
136
236
|
*
|
|
137
237
|
* Spec §5.8.
|
|
@@ -195,7 +295,7 @@ declare class AvsbServer {
|
|
|
195
295
|
private readyPromise;
|
|
196
296
|
private pendingEvents;
|
|
197
297
|
/**
|
|
198
|
-
* §6
|
|
298
|
+
* §6: `client.utils.*` surface. Constructed lazily on first access so
|
|
199
299
|
* the utils package is only imported when actually consumed.
|
|
200
300
|
*
|
|
201
301
|
* The server's event surface (`ServerEventMap`) is a subset of the
|
|
@@ -209,10 +309,19 @@ declare class AvsbServer {
|
|
|
209
309
|
* Server-side datasets read surface (e-commerce suite Phase 2).
|
|
210
310
|
* Constructed lazily on first access, mirroring the `utils` getter.
|
|
211
311
|
* `client.datasets.get(slug, key)` resolves the live version from the
|
|
212
|
-
* current datafile and never throws
|
|
312
|
+
* current datafile and never throws; see ./datasets.ts.
|
|
213
313
|
*/
|
|
214
314
|
private _datasets;
|
|
215
315
|
get datasets(): DatasetsClient;
|
|
316
|
+
/**
|
|
317
|
+
* Server-side recommendations surface (e-commerce suite overhaul, C3).
|
|
318
|
+
* Constructed lazily on first access, mirroring the `datasets` getter.
|
|
319
|
+
* `client.recs.get({ recipe, … })` resolves the recipe registry from the
|
|
320
|
+
* current datafile and never throws; impressions/clicks ride the server
|
|
321
|
+
* tracker's queue and revenue attaches via the existing `trackPurchase`.
|
|
322
|
+
*/
|
|
323
|
+
private _recs;
|
|
324
|
+
get recs(): RecsClient;
|
|
216
325
|
constructor(options: AvsbServerOptions);
|
|
217
326
|
onReady(): Promise<InitResult>;
|
|
218
327
|
private initializeAndReady;
|
|
@@ -303,6 +412,6 @@ declare class RedisStickyBucketService implements StickyBucketService {
|
|
|
303
412
|
|
|
304
413
|
declare function createDecisionLog(): DecisionLog;
|
|
305
414
|
|
|
306
|
-
declare const SDK_VERSION = "
|
|
415
|
+
declare const SDK_VERSION = "1.6.2";
|
|
307
416
|
|
|
308
|
-
export { AvsbServer, type AvsbServerOptions, type DatasetGetResult, type DatasetsClient, type EventOptions, type GetAllFlagsOptions, type GetFlagOptions, InMemoryStickyBucketService, type MiddlewareConfig, type PurchaseOrder, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type UserBoundClient, createDecisionLog, expressMiddleware, fastifyPlugin };
|
|
417
|
+
export { AvsbServer, type AvsbServerOptions, type DatafileRecipe, type DatasetGetResult, type DatasetsClient, type EventOptions, type GetAllFlagsOptions, type GetFlagOptions, InMemoryStickyBucketService, type MiddlewareConfig, type PurchaseOrder, type RecProduct, type RecsAttribution, type RecsClient, type RecsGetRequest, type RecsResult, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type UserBoundClient, createDecisionLog, expressMiddleware, fastifyPlugin };
|
package/dist/index.js
CHANGED
|
@@ -23,7 +23,7 @@ function createDatasetsClient(options) {
|
|
|
23
23
|
const requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
|
|
24
24
|
async function get(slug, key) {
|
|
25
25
|
if (!slug || !key) {
|
|
26
|
-
logger.warn("datasets.get called with an empty slug or key
|
|
26
|
+
logger.warn("datasets.get called with an empty slug or key; returning not found");
|
|
27
27
|
return miss(slug, null);
|
|
28
28
|
}
|
|
29
29
|
const datafile = getDatafile();
|
|
@@ -33,11 +33,11 @@ function createDatasetsClient(options) {
|
|
|
33
33
|
}
|
|
34
34
|
const dataset = datafile.datasets?.find((d) => d.slug === slug);
|
|
35
35
|
if (!dataset) {
|
|
36
|
-
logger.debug(`Dataset "${slug}" not present in datafile
|
|
36
|
+
logger.debug(`Dataset "${slug}" not present in datafile; returning not found`);
|
|
37
37
|
return miss(slug, null);
|
|
38
38
|
}
|
|
39
39
|
if (!dataset.liveVersion) {
|
|
40
|
-
logger.debug(`Dataset "${slug}" has no live version
|
|
40
|
+
logger.debug(`Dataset "${slug}" has no live version; returning not found`);
|
|
41
41
|
return miss(slug, null);
|
|
42
42
|
}
|
|
43
43
|
const base = datafile.collectEndpoint.replace(/\/+$/, "");
|
|
@@ -65,6 +65,205 @@ function createDatasetsClient(options) {
|
|
|
65
65
|
return { get };
|
|
66
66
|
}
|
|
67
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; 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; 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; dropped");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!opts || !opts.visitorId || !opts.experimentId || !opts.variationId) {
|
|
240
|
+
logger.warn("recs.trackClick requires visitorId/experimentId/variationId; 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
|
+
|
|
68
267
|
// src/decisionLog.ts
|
|
69
268
|
function createDecisionLog() {
|
|
70
269
|
const entries = [];
|
|
@@ -267,7 +466,7 @@ import {
|
|
|
267
466
|
} from "@avsbhq/utils/streaming";
|
|
268
467
|
|
|
269
468
|
// src/version.ts
|
|
270
|
-
var SDK_VERSION = "
|
|
469
|
+
var SDK_VERSION = "1.6.2";
|
|
271
470
|
|
|
272
471
|
// src/UserBoundClient.ts
|
|
273
472
|
function createUserBoundClient(server, context) {
|
|
@@ -414,7 +613,7 @@ var AvsbServer = class {
|
|
|
414
613
|
readyPromise = null;
|
|
415
614
|
pendingEvents = [];
|
|
416
615
|
/**
|
|
417
|
-
* §6
|
|
616
|
+
* §6: `client.utils.*` surface. Constructed lazily on first access so
|
|
418
617
|
* the utils package is only imported when actually consumed.
|
|
419
618
|
*
|
|
420
619
|
* The server's event surface (`ServerEventMap`) is a subset of the
|
|
@@ -447,7 +646,7 @@ var AvsbServer = class {
|
|
|
447
646
|
* Server-side datasets read surface (e-commerce suite Phase 2).
|
|
448
647
|
* Constructed lazily on first access, mirroring the `utils` getter.
|
|
449
648
|
* `client.datasets.get(slug, key)` resolves the live version from the
|
|
450
|
-
* current datafile and never throws
|
|
649
|
+
* current datafile and never throws; see ./datasets.ts.
|
|
451
650
|
*/
|
|
452
651
|
_datasets = null;
|
|
453
652
|
get datasets() {
|
|
@@ -460,6 +659,25 @@ var AvsbServer = class {
|
|
|
460
659
|
}
|
|
461
660
|
return this._datasets;
|
|
462
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
|
+
}
|
|
463
681
|
constructor(options) {
|
|
464
682
|
this.sdkKey = options.sdkKey;
|
|
465
683
|
this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
|
|
@@ -582,7 +800,7 @@ var AvsbServer = class {
|
|
|
582
800
|
const decisionLog = options?.decisionLog;
|
|
583
801
|
if (!context) {
|
|
584
802
|
this.logger.warn(
|
|
585
|
-
`getFlag called without context for flagKey="${flagKey}"
|
|
803
|
+
`getFlag called without context for flagKey="${flagKey}"; returning default`
|
|
586
804
|
);
|
|
587
805
|
return notFoundFlag2(defaultValue, "no context provided");
|
|
588
806
|
}
|
|
@@ -844,7 +1062,7 @@ var AvsbServer = class {
|
|
|
844
1062
|
const mismatch = checkExpectedType(datafileFlag, expectedType, flag.value);
|
|
845
1063
|
if (mismatch) {
|
|
846
1064
|
this.logger.warn(
|
|
847
|
-
`Type mismatch on flag "${flagKey}": ${mismatch}
|
|
1065
|
+
`Type mismatch on flag "${flagKey}": ${mismatch}; returning default`
|
|
848
1066
|
);
|
|
849
1067
|
return notFoundFlag2(defaultValue, mismatch);
|
|
850
1068
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avsbhq/node",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.cjs",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"test:watch": "vitest"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@avsbhq/core": "1.
|
|
37
|
-
"@avsbhq/utils": "1.0.
|
|
36
|
+
"@avsbhq/core": "1.4.0",
|
|
37
|
+
"@avsbhq/utils": "1.0.2"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"tsup": "^8.0.0",
|