@avsbhq/node 1.5.0 → 1.6.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/dist/index.cjs +1 -219
- package/dist/index.d.cts +3 -108
- package/dist/index.d.ts +3 -108
- package/dist/index.js +1 -219
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -92,205 +92,6 @@ 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 \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
|
-
|
|
294
95
|
// src/decisionLog.ts
|
|
295
96
|
function createDecisionLog() {
|
|
296
97
|
const entries = [];
|
|
@@ -490,7 +291,7 @@ function createServerTracker(options) {
|
|
|
490
291
|
var import_streaming = require("@avsbhq/utils/streaming");
|
|
491
292
|
|
|
492
293
|
// src/version.ts
|
|
493
|
-
var SDK_VERSION = "0.
|
|
294
|
+
var SDK_VERSION = "0.3.0";
|
|
494
295
|
|
|
495
296
|
// src/UserBoundClient.ts
|
|
496
297
|
function createUserBoundClient(server, context) {
|
|
@@ -683,25 +484,6 @@ var AvsbServer = class {
|
|
|
683
484
|
}
|
|
684
485
|
return this._datasets;
|
|
685
486
|
}
|
|
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
|
-
}
|
|
705
487
|
constructor(options) {
|
|
706
488
|
this.sdkKey = options.sdkKey;
|
|
707
489
|
this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
|
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,
|
|
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';
|
|
3
3
|
import { AvsbUtils } from '@avsbhq/utils';
|
|
4
4
|
export { AvsbUtils } from '@avsbhq/utils';
|
|
5
5
|
export { StreamingClient, StreamingOptions, createStreamingClient } from '@avsbhq/utils/streaming';
|
|
@@ -82,13 +82,6 @@ interface PurchaseOrder {
|
|
|
82
82
|
coupon?: string;
|
|
83
83
|
items?: Array<{
|
|
84
84
|
sku: string;
|
|
85
|
-
/**
|
|
86
|
-
* Canonical product-grain key (e-commerce suite overhaul, contract C1).
|
|
87
|
-
* Pass when known so order items join catalog/recommendation analytics
|
|
88
|
-
* without alias resolution; the ingestion worker derives it from `sku`
|
|
89
|
-
* otherwise.
|
|
90
|
-
*/
|
|
91
|
-
productKey?: string;
|
|
92
85
|
name?: string;
|
|
93
86
|
price?: number;
|
|
94
87
|
quantity?: number;
|
|
@@ -131,95 +124,6 @@ interface DatasetsClient {
|
|
|
131
124
|
get(slug: string, key: string): Promise<DatasetGetResult>;
|
|
132
125
|
}
|
|
133
126
|
|
|
134
|
-
/**
|
|
135
|
-
* Server-side recommendations client (e-commerce suite overhaul, contract C3).
|
|
136
|
-
*
|
|
137
|
-
* Mirrors ./datasets.ts: resolves a recipe handle against the datafile's
|
|
138
|
-
* recipe registry (`datafile.recipes`, additive) and POSTs the standard
|
|
139
|
-
* resolve body to the edge worker:
|
|
140
|
-
*
|
|
141
|
-
* POST {ingestionHost}/v1/recs/resolve
|
|
142
|
-
*
|
|
143
|
-
* The ingestion host is derived from the datafile's `collectEndpoint`. The
|
|
144
|
-
* worker's key cache resolves SDK keys as well as snippet keys, so the body's
|
|
145
|
-
* key field simply carries the SDK key.
|
|
146
|
-
*
|
|
147
|
-
* Attribution is OPT-IN server-side: `get()` fires a `rec:impression` (via
|
|
148
|
-
* the server tracker) only when the caller passes `attribution` — a server
|
|
149
|
-
* has no ambient variation context the way a browser harness does.
|
|
150
|
-
*
|
|
151
|
-
* Error posture matches datasets/flag evaluation: this client NEVER throws.
|
|
152
|
-
* Unknown recipe, SDK not ready, HTTP errors, timeouts and network failures
|
|
153
|
-
* all degrade to `{ items: [], served: false }`.
|
|
154
|
-
*/
|
|
155
|
-
interface RecProduct {
|
|
156
|
-
id: string;
|
|
157
|
-
title?: string;
|
|
158
|
-
image?: string;
|
|
159
|
-
href?: string;
|
|
160
|
-
category?: string;
|
|
161
|
-
/** MINOR units (ISO-4217). */
|
|
162
|
-
price?: number;
|
|
163
|
-
/** MINOR units. */
|
|
164
|
-
compareAtPrice?: number;
|
|
165
|
-
currency?: string;
|
|
166
|
-
availability?: 'in_stock' | 'out_of_stock' | 'removed';
|
|
167
|
-
}
|
|
168
|
-
interface RecsAttribution {
|
|
169
|
-
visitorId: string;
|
|
170
|
-
experimentId: string;
|
|
171
|
-
variationId: string;
|
|
172
|
-
}
|
|
173
|
-
interface RecsGetRequest {
|
|
174
|
-
recipe: string;
|
|
175
|
-
context?: {
|
|
176
|
-
/** Single seed product key (product page). */
|
|
177
|
-
seed?: string;
|
|
178
|
-
/** Up to 3 seed product keys (cart page). */
|
|
179
|
-
seeds?: string[];
|
|
180
|
-
};
|
|
181
|
-
maxItems?: number;
|
|
182
|
-
excludeOutOfStock?: boolean;
|
|
183
|
-
/** When present, a rec:impression is queued with these ids on success. */
|
|
184
|
-
attribution?: RecsAttribution;
|
|
185
|
-
}
|
|
186
|
-
interface RecsResult {
|
|
187
|
-
items: RecProduct[];
|
|
188
|
-
served: boolean;
|
|
189
|
-
recipe: string;
|
|
190
|
-
/** Slug of the chain step that served, null on a miss. */
|
|
191
|
-
source: string | null;
|
|
192
|
-
fallbackStep: number | null;
|
|
193
|
-
/** True when the resolve failed (timeout / network / HTTP / parse). */
|
|
194
|
-
degraded: boolean;
|
|
195
|
-
}
|
|
196
|
-
interface RecsClient {
|
|
197
|
-
/** Resolve items for a recipe. Never throws. */
|
|
198
|
-
get(req: RecsGetRequest): Promise<RecsResult>;
|
|
199
|
-
/**
|
|
200
|
-
* Queue a rec:click for a product the shopper clicked. Pass `recipe` when
|
|
201
|
-
* known — results group click-through by (variation, recipe).
|
|
202
|
-
*/
|
|
203
|
-
trackClick(productId: string, opts: RecsAttribution & {
|
|
204
|
-
position?: number;
|
|
205
|
-
recipe?: string;
|
|
206
|
-
}): void;
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* One entry of the datafile recipe registry (contract C3). Additive on
|
|
210
|
-
* FlagDatafile — SDKs on older datafiles see it absent.
|
|
211
|
-
*/
|
|
212
|
-
interface DatafileRecipe {
|
|
213
|
-
recipe: string;
|
|
214
|
-
chain: Array<{
|
|
215
|
-
slug: string;
|
|
216
|
-
version: number;
|
|
217
|
-
}>;
|
|
218
|
-
excludeOutOfStock: boolean;
|
|
219
|
-
sessionAffinity: boolean;
|
|
220
|
-
merch?: unknown[];
|
|
221
|
-
}
|
|
222
|
-
|
|
223
127
|
/**
|
|
224
128
|
* A per-request scoped client with a bound EvalContext.
|
|
225
129
|
*
|
|
@@ -309,15 +213,6 @@ declare class AvsbServer {
|
|
|
309
213
|
*/
|
|
310
214
|
private _datasets;
|
|
311
215
|
get datasets(): DatasetsClient;
|
|
312
|
-
/**
|
|
313
|
-
* Server-side recommendations surface (e-commerce suite overhaul, C3).
|
|
314
|
-
* Constructed lazily on first access, mirroring the `datasets` getter.
|
|
315
|
-
* `client.recs.get({ recipe, … })` resolves the recipe registry from the
|
|
316
|
-
* current datafile and never throws; impressions/clicks ride the server
|
|
317
|
-
* tracker's queue and revenue attaches via the existing `trackPurchase`.
|
|
318
|
-
*/
|
|
319
|
-
private _recs;
|
|
320
|
-
get recs(): RecsClient;
|
|
321
216
|
constructor(options: AvsbServerOptions);
|
|
322
217
|
onReady(): Promise<InitResult>;
|
|
323
218
|
private initializeAndReady;
|
|
@@ -408,6 +303,6 @@ declare class RedisStickyBucketService implements StickyBucketService {
|
|
|
408
303
|
|
|
409
304
|
declare function createDecisionLog(): DecisionLog;
|
|
410
305
|
|
|
411
|
-
declare const SDK_VERSION = "0.
|
|
306
|
+
declare const SDK_VERSION = "0.3.0";
|
|
412
307
|
|
|
413
|
-
export { AvsbServer, type AvsbServerOptions, type
|
|
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 };
|
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,
|
|
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';
|
|
3
3
|
import { AvsbUtils } from '@avsbhq/utils';
|
|
4
4
|
export { AvsbUtils } from '@avsbhq/utils';
|
|
5
5
|
export { StreamingClient, StreamingOptions, createStreamingClient } from '@avsbhq/utils/streaming';
|
|
@@ -82,13 +82,6 @@ interface PurchaseOrder {
|
|
|
82
82
|
coupon?: string;
|
|
83
83
|
items?: Array<{
|
|
84
84
|
sku: string;
|
|
85
|
-
/**
|
|
86
|
-
* Canonical product-grain key (e-commerce suite overhaul, contract C1).
|
|
87
|
-
* Pass when known so order items join catalog/recommendation analytics
|
|
88
|
-
* without alias resolution; the ingestion worker derives it from `sku`
|
|
89
|
-
* otherwise.
|
|
90
|
-
*/
|
|
91
|
-
productKey?: string;
|
|
92
85
|
name?: string;
|
|
93
86
|
price?: number;
|
|
94
87
|
quantity?: number;
|
|
@@ -131,95 +124,6 @@ interface DatasetsClient {
|
|
|
131
124
|
get(slug: string, key: string): Promise<DatasetGetResult>;
|
|
132
125
|
}
|
|
133
126
|
|
|
134
|
-
/**
|
|
135
|
-
* Server-side recommendations client (e-commerce suite overhaul, contract C3).
|
|
136
|
-
*
|
|
137
|
-
* Mirrors ./datasets.ts: resolves a recipe handle against the datafile's
|
|
138
|
-
* recipe registry (`datafile.recipes`, additive) and POSTs the standard
|
|
139
|
-
* resolve body to the edge worker:
|
|
140
|
-
*
|
|
141
|
-
* POST {ingestionHost}/v1/recs/resolve
|
|
142
|
-
*
|
|
143
|
-
* The ingestion host is derived from the datafile's `collectEndpoint`. The
|
|
144
|
-
* worker's key cache resolves SDK keys as well as snippet keys, so the body's
|
|
145
|
-
* key field simply carries the SDK key.
|
|
146
|
-
*
|
|
147
|
-
* Attribution is OPT-IN server-side: `get()` fires a `rec:impression` (via
|
|
148
|
-
* the server tracker) only when the caller passes `attribution` — a server
|
|
149
|
-
* has no ambient variation context the way a browser harness does.
|
|
150
|
-
*
|
|
151
|
-
* Error posture matches datasets/flag evaluation: this client NEVER throws.
|
|
152
|
-
* Unknown recipe, SDK not ready, HTTP errors, timeouts and network failures
|
|
153
|
-
* all degrade to `{ items: [], served: false }`.
|
|
154
|
-
*/
|
|
155
|
-
interface RecProduct {
|
|
156
|
-
id: string;
|
|
157
|
-
title?: string;
|
|
158
|
-
image?: string;
|
|
159
|
-
href?: string;
|
|
160
|
-
category?: string;
|
|
161
|
-
/** MINOR units (ISO-4217). */
|
|
162
|
-
price?: number;
|
|
163
|
-
/** MINOR units. */
|
|
164
|
-
compareAtPrice?: number;
|
|
165
|
-
currency?: string;
|
|
166
|
-
availability?: 'in_stock' | 'out_of_stock' | 'removed';
|
|
167
|
-
}
|
|
168
|
-
interface RecsAttribution {
|
|
169
|
-
visitorId: string;
|
|
170
|
-
experimentId: string;
|
|
171
|
-
variationId: string;
|
|
172
|
-
}
|
|
173
|
-
interface RecsGetRequest {
|
|
174
|
-
recipe: string;
|
|
175
|
-
context?: {
|
|
176
|
-
/** Single seed product key (product page). */
|
|
177
|
-
seed?: string;
|
|
178
|
-
/** Up to 3 seed product keys (cart page). */
|
|
179
|
-
seeds?: string[];
|
|
180
|
-
};
|
|
181
|
-
maxItems?: number;
|
|
182
|
-
excludeOutOfStock?: boolean;
|
|
183
|
-
/** When present, a rec:impression is queued with these ids on success. */
|
|
184
|
-
attribution?: RecsAttribution;
|
|
185
|
-
}
|
|
186
|
-
interface RecsResult {
|
|
187
|
-
items: RecProduct[];
|
|
188
|
-
served: boolean;
|
|
189
|
-
recipe: string;
|
|
190
|
-
/** Slug of the chain step that served, null on a miss. */
|
|
191
|
-
source: string | null;
|
|
192
|
-
fallbackStep: number | null;
|
|
193
|
-
/** True when the resolve failed (timeout / network / HTTP / parse). */
|
|
194
|
-
degraded: boolean;
|
|
195
|
-
}
|
|
196
|
-
interface RecsClient {
|
|
197
|
-
/** Resolve items for a recipe. Never throws. */
|
|
198
|
-
get(req: RecsGetRequest): Promise<RecsResult>;
|
|
199
|
-
/**
|
|
200
|
-
* Queue a rec:click for a product the shopper clicked. Pass `recipe` when
|
|
201
|
-
* known — results group click-through by (variation, recipe).
|
|
202
|
-
*/
|
|
203
|
-
trackClick(productId: string, opts: RecsAttribution & {
|
|
204
|
-
position?: number;
|
|
205
|
-
recipe?: string;
|
|
206
|
-
}): void;
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* One entry of the datafile recipe registry (contract C3). Additive on
|
|
210
|
-
* FlagDatafile — SDKs on older datafiles see it absent.
|
|
211
|
-
*/
|
|
212
|
-
interface DatafileRecipe {
|
|
213
|
-
recipe: string;
|
|
214
|
-
chain: Array<{
|
|
215
|
-
slug: string;
|
|
216
|
-
version: number;
|
|
217
|
-
}>;
|
|
218
|
-
excludeOutOfStock: boolean;
|
|
219
|
-
sessionAffinity: boolean;
|
|
220
|
-
merch?: unknown[];
|
|
221
|
-
}
|
|
222
|
-
|
|
223
127
|
/**
|
|
224
128
|
* A per-request scoped client with a bound EvalContext.
|
|
225
129
|
*
|
|
@@ -309,15 +213,6 @@ declare class AvsbServer {
|
|
|
309
213
|
*/
|
|
310
214
|
private _datasets;
|
|
311
215
|
get datasets(): DatasetsClient;
|
|
312
|
-
/**
|
|
313
|
-
* Server-side recommendations surface (e-commerce suite overhaul, C3).
|
|
314
|
-
* Constructed lazily on first access, mirroring the `datasets` getter.
|
|
315
|
-
* `client.recs.get({ recipe, … })` resolves the recipe registry from the
|
|
316
|
-
* current datafile and never throws; impressions/clicks ride the server
|
|
317
|
-
* tracker's queue and revenue attaches via the existing `trackPurchase`.
|
|
318
|
-
*/
|
|
319
|
-
private _recs;
|
|
320
|
-
get recs(): RecsClient;
|
|
321
216
|
constructor(options: AvsbServerOptions);
|
|
322
217
|
onReady(): Promise<InitResult>;
|
|
323
218
|
private initializeAndReady;
|
|
@@ -408,6 +303,6 @@ declare class RedisStickyBucketService implements StickyBucketService {
|
|
|
408
303
|
|
|
409
304
|
declare function createDecisionLog(): DecisionLog;
|
|
410
305
|
|
|
411
|
-
declare const SDK_VERSION = "0.
|
|
306
|
+
declare const SDK_VERSION = "0.3.0";
|
|
412
307
|
|
|
413
|
-
export { AvsbServer, type AvsbServerOptions, type
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -65,205 +65,6 @@ 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 \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
|
-
|
|
267
68
|
// src/decisionLog.ts
|
|
268
69
|
function createDecisionLog() {
|
|
269
70
|
const entries = [];
|
|
@@ -466,7 +267,7 @@ import {
|
|
|
466
267
|
} from "@avsbhq/utils/streaming";
|
|
467
268
|
|
|
468
269
|
// src/version.ts
|
|
469
|
-
var SDK_VERSION = "0.
|
|
270
|
+
var SDK_VERSION = "0.3.0";
|
|
470
271
|
|
|
471
272
|
// src/UserBoundClient.ts
|
|
472
273
|
function createUserBoundClient(server, context) {
|
|
@@ -659,25 +460,6 @@ var AvsbServer = class {
|
|
|
659
460
|
}
|
|
660
461
|
return this._datasets;
|
|
661
462
|
}
|
|
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
|
-
}
|
|
681
463
|
constructor(options) {
|
|
682
464
|
this.sdkKey = options.sdkKey;
|
|
683
465
|
this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
|