@recapi/client 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +562 -2
- package/index.d.ts +188 -0
- package/package.json +26 -4
- package/src/client.js +678 -0
- package/src/history.js +500 -0
- package/src/index.js +20 -0
- package/src/storage.js +156 -0
- package/src/transport.js +195 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recapi client: local history, event batching, recommendations and
|
|
3
|
+
* automatic attribution.
|
|
4
|
+
*
|
|
5
|
+
* @module client
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
applyEvent,
|
|
10
|
+
decodeState,
|
|
11
|
+
emptyHistory,
|
|
12
|
+
encodeState,
|
|
13
|
+
fromSnapshot,
|
|
14
|
+
isEmpty,
|
|
15
|
+
isId,
|
|
16
|
+
merge,
|
|
17
|
+
mergeSnapshot,
|
|
18
|
+
resolveConfig,
|
|
19
|
+
toWire
|
|
20
|
+
} from './history.js';
|
|
21
|
+
import { createStore, defaultStorage, storageKey } from './storage.js';
|
|
22
|
+
import { RecapiError, createTransport, defaultSleep, withRetry } from './transport.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Attribution windows (README "Attribution"): an event on a shown item
|
|
26
|
+
* within 30 minutes of the display is the click from the widget, about one
|
|
27
|
+
* visit (Nosto, Clerk). Later the shopper has likely found the item some
|
|
28
|
+
* other way, so only a clicked item's events (cart, purchase) are
|
|
29
|
+
* attributed, for 24 hours after the click (Rebuy).
|
|
30
|
+
*/
|
|
31
|
+
const DISPLAY_WINDOW_MS = 30 * 60 * 1000;
|
|
32
|
+
const CLICK_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
/** Bounds the item→recommendation map so storage stays small on heavy pages. */
|
|
34
|
+
const MAX_SHOWN_ITEMS = 500;
|
|
35
|
+
/** Server limit for one `POST /v1/events`. */
|
|
36
|
+
const MAX_EVENTS_PER_REQUEST = 1000;
|
|
37
|
+
/** Upper bound for the flush delay after repeated failures (e.g. offline). */
|
|
38
|
+
const MAX_FLUSH_BACKOFF_MS = 60000;
|
|
39
|
+
/**
|
|
40
|
+
* How long `recommend()` waits before it falls back to popular items, and
|
|
41
|
+
* the fallback request after it. A widget above the fold that is still empty
|
|
42
|
+
* after 1.5 s has lost the shopper; the general `timeoutMs` (5 s) suits
|
|
43
|
+
* events, not that.
|
|
44
|
+
*/
|
|
45
|
+
const RECOMMEND_TIMEOUT_MS = 1500;
|
|
46
|
+
/** Server defaults and limits for `count` (docs/api.md). */
|
|
47
|
+
const DEFAULT_COUNT = 10;
|
|
48
|
+
const MAX_COUNT = 100;
|
|
49
|
+
/**
|
|
50
|
+
* Names for the recommended level scale (docs/api.md "Interaction levels"),
|
|
51
|
+
* so integrators don't have to learn the numbers. The API itself only
|
|
52
|
+
* takes integers; the name is resolved here.
|
|
53
|
+
*/
|
|
54
|
+
const LEVEL_NAMES = Object.freeze({ view: 3, cart: 5, purchase: 10, reject: -10 });
|
|
55
|
+
/** The API's limit on a request's `exclude` list (docs/api.md). */
|
|
56
|
+
const MAX_EXCLUDE = 500;
|
|
57
|
+
/**
|
|
58
|
+
* Only a public key may travel in a URL: the API refuses a secret key from
|
|
59
|
+
* `?key=`, because URLs end up in proxy and CDN logs.
|
|
60
|
+
*/
|
|
61
|
+
const PUBLIC_KEY_PREFIX = 'rk_pub_';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {import('./storage.js').StorageAdapter} StorageAdapter
|
|
65
|
+
* @typedef {import('./history.js').HistoryConfig} HistoryConfig
|
|
66
|
+
* @typedef {import('./history.js').HistoryState} HistoryState
|
|
67
|
+
* @typedef {import('./history.js').ServerSnapshot} ServerSnapshot
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} ClientOptions
|
|
72
|
+
* @property {string} apiKey Public key (`rk_pub_…`) in browsers.
|
|
73
|
+
* @property {string} baseUrl Your recapi API origin, e.g. `https://api.example.com`.
|
|
74
|
+
* @property {StorageAdapter} [storage] Defaults to localStorage, else memory.
|
|
75
|
+
* @property {string} [project] Storage namespace; defaults to the first 16
|
|
76
|
+
* characters of the API key.
|
|
77
|
+
* @property {string} [placement] Default placement for `recommend()`.
|
|
78
|
+
* @property {number} [flushIntervalMs]
|
|
79
|
+
* @property {number} [maxBatch]
|
|
80
|
+
* @property {number} [timeoutMs] Per-request timeout.
|
|
81
|
+
* @property {Partial<HistoryConfig>} [historyConfig]
|
|
82
|
+
* @property {boolean} [consent] Initial storage consent (default `true`).
|
|
83
|
+
* @property {boolean} [respectDoNotTrack] Start without consent when the
|
|
84
|
+
* browser sends Do Not Track or Global Privacy Control.
|
|
85
|
+
* @property {(error: unknown) => void} [onError] Receives background errors,
|
|
86
|
+
* and the errors `recommend()` fell back from.
|
|
87
|
+
* @property {() => number} [now] Clock in unix ms.
|
|
88
|
+
* @property {typeof fetch} [fetch]
|
|
89
|
+
* @property {(ms: number) => Promise<void>} [sleep] Backoff sleep (tests).
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Creates a client. Nothing is sent until `track()`/`recommend()` is called.
|
|
94
|
+
*
|
|
95
|
+
* @param {ClientOptions} options
|
|
96
|
+
*/
|
|
97
|
+
export function createClient(options) {
|
|
98
|
+
const {
|
|
99
|
+
apiKey,
|
|
100
|
+
baseUrl,
|
|
101
|
+
placement: defaultPlacement,
|
|
102
|
+
flushIntervalMs = 2000,
|
|
103
|
+
timeoutMs = 5000,
|
|
104
|
+
now = Date.now,
|
|
105
|
+
sleep = defaultSleep
|
|
106
|
+
} = options ?? {};
|
|
107
|
+
if (typeof apiKey !== 'string' || !apiKey) throw new TypeError('apiKey is required');
|
|
108
|
+
if (typeof baseUrl !== 'string' || !baseUrl) throw new TypeError('baseUrl is required');
|
|
109
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
110
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('fetch is not available; pass options.fetch');
|
|
111
|
+
|
|
112
|
+
const maxBatch = Math.min(Math.max(1, options.maxBatch ?? 100), MAX_EVENTS_PER_REQUEST);
|
|
113
|
+
const maxQueue = Math.max(MAX_EVENTS_PER_REQUEST, maxBatch);
|
|
114
|
+
const config = resolveConfig(options.historyConfig);
|
|
115
|
+
const report = (error) => {
|
|
116
|
+
try {
|
|
117
|
+
options.onError?.(error);
|
|
118
|
+
} catch {
|
|
119
|
+
// A faulty error handler must not break tracking.
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const transport = createTransport({ baseUrl, apiKey, fetch: fetchImpl, timeoutMs });
|
|
123
|
+
const store = createStore(
|
|
124
|
+
options.storage ?? defaultStorage(report),
|
|
125
|
+
storageKey(options.project ?? apiKey.slice(0, 16)),
|
|
126
|
+
decodePersisted,
|
|
127
|
+
report
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
let consent = options.consent ?? true;
|
|
131
|
+
if (options.respectDoNotTrack && doNotTrackEnabled()) consent = false;
|
|
132
|
+
|
|
133
|
+
/** @type {HistoryState} */
|
|
134
|
+
let history = emptyHistory();
|
|
135
|
+
let userId = null;
|
|
136
|
+
let anonId = null;
|
|
137
|
+
/**
|
|
138
|
+
* item id → [recommendation id, shown at, clicked at?]; insertion order =
|
|
139
|
+
* age, for eviction.
|
|
140
|
+
*/
|
|
141
|
+
let shown = new Map();
|
|
142
|
+
/**
|
|
143
|
+
* Set once the history holds recapi's stored one (a snapshot), so it is
|
|
144
|
+
* merged only once, as in the server libraries: scores add up in a merge.
|
|
145
|
+
*/
|
|
146
|
+
let restored = false;
|
|
147
|
+
const queue = [];
|
|
148
|
+
let chain = Promise.resolve();
|
|
149
|
+
let timer = null;
|
|
150
|
+
let failures = 0;
|
|
151
|
+
let destroyed = false;
|
|
152
|
+
/** False after a failed write: storage is then stale and memory is the truth. */
|
|
153
|
+
let synced = true;
|
|
154
|
+
|
|
155
|
+
if (consent) {
|
|
156
|
+
reload();
|
|
157
|
+
if (!anonId) {
|
|
158
|
+
anonId = uuid();
|
|
159
|
+
persist();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function reload() {
|
|
164
|
+
if (!synced) return;
|
|
165
|
+
const saved = store.load();
|
|
166
|
+
if (!saved) return;
|
|
167
|
+
({ history, userId, anonId, restored } = saved);
|
|
168
|
+
shown = new Map(saved.shown);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function persist() {
|
|
172
|
+
if (!consent) return;
|
|
173
|
+
const t = now();
|
|
174
|
+
for (const [item, entry] of shown) if (!attributable(entry, t)) shown.delete(item);
|
|
175
|
+
synced = store.save({
|
|
176
|
+
anonId,
|
|
177
|
+
userId,
|
|
178
|
+
history: encodeState(history),
|
|
179
|
+
shown: [...shown].map(([item, entry]) => [item, ...entry]),
|
|
180
|
+
// Only when set: browsers (public keys) never get a snapshot.
|
|
181
|
+
restored: restored || undefined
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read-modify-write against storage so several tabs sharing localStorage
|
|
187
|
+
* build one history instead of overwriting each other's updates.
|
|
188
|
+
*/
|
|
189
|
+
function mutate(fn) {
|
|
190
|
+
if (consent) reload();
|
|
191
|
+
fn();
|
|
192
|
+
persist();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The recommendation an event on `itemId` at `t` belongs to, if any. The
|
|
197
|
+
* first event above level 0 within the display window is the click on the
|
|
198
|
+
* item: it opens the click window and is stored with the entry.
|
|
199
|
+
*/
|
|
200
|
+
function attributedRecommendation(itemId, level, t) {
|
|
201
|
+
const entry = shown.get(itemId);
|
|
202
|
+
if (!entry || !attributable(entry, t)) return undefined;
|
|
203
|
+
if (level > 0 && entry.length < 3) entry.push(t);
|
|
204
|
+
return entry[0];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function enqueue(event) {
|
|
208
|
+
queue.push(event);
|
|
209
|
+
if (queue.length > maxQueue) dropOverflow();
|
|
210
|
+
if (queue.length >= maxBatch) flush();
|
|
211
|
+
else schedule();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function dropOverflow() {
|
|
215
|
+
const dropped = queue.splice(0, queue.length - maxQueue).length;
|
|
216
|
+
report(new RecapiError('queue_overflow', `dropped ${dropped} oldest events`));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function schedule() {
|
|
220
|
+
if (timer || destroyed || !queue.length) return;
|
|
221
|
+
const delay = Math.min(flushIntervalMs * 2 ** failures, MAX_FLUSH_BACKOFF_MS);
|
|
222
|
+
timer = setTimeout(() => {
|
|
223
|
+
timer = null;
|
|
224
|
+
flush();
|
|
225
|
+
}, delay);
|
|
226
|
+
timer.unref?.();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Always keepalive, so a batch already in flight when the user navigates
|
|
231
|
+
* away still completes.
|
|
232
|
+
*/
|
|
233
|
+
async function sendBatch(events, { retry }) {
|
|
234
|
+
const send = () => transport.request({ method: 'POST', path: '/v1/events', body: { events }, keepalive: true });
|
|
235
|
+
const response = retry ? await withRetry(send, { sleep }) : await send();
|
|
236
|
+
if (response?.rejected?.length) {
|
|
237
|
+
report(new RecapiError('event_rejected', `${response.rejected.length} event(s) rejected`, {
|
|
238
|
+
status: 202,
|
|
239
|
+
details: response.rejected.map((r) => ({ ...r, event: events[r.index] }))
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Puts a failed batch back at the head of the queue if it may succeed later. */
|
|
245
|
+
function handleSendError(error, events) {
|
|
246
|
+
report(error);
|
|
247
|
+
if (error instanceof RecapiError && error.retryable) {
|
|
248
|
+
queue.unshift(...events);
|
|
249
|
+
if (queue.length > maxQueue) dropOverflow();
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function drain() {
|
|
256
|
+
if (timer) clearTimeout(timer);
|
|
257
|
+
timer = null;
|
|
258
|
+
while (queue.length) {
|
|
259
|
+
const events = queue.splice(0, maxBatch);
|
|
260
|
+
try {
|
|
261
|
+
await sendBatch(events, { retry: true });
|
|
262
|
+
failures = 0;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (handleSendError(error, events)) {
|
|
265
|
+
failures++;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
schedule();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Sends everything immediately without retries, outside the serial chain:
|
|
275
|
+
* the page may be gone before a sleeping retry wakes up.
|
|
276
|
+
*/
|
|
277
|
+
function flushOnHide() {
|
|
278
|
+
while (queue.length) {
|
|
279
|
+
const events = queue.splice(0, maxBatch);
|
|
280
|
+
sendBatch(events, { retry: false }).catch((error) => {
|
|
281
|
+
handleSendError(error, events);
|
|
282
|
+
schedule();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Sends all queued events. Never rejects: failures go to `onError`, and
|
|
289
|
+
* retryable batches stay queued for the next automatic flush.
|
|
290
|
+
*
|
|
291
|
+
* @returns {Promise<void>}
|
|
292
|
+
*/
|
|
293
|
+
function flush() {
|
|
294
|
+
chain = chain.then(drain, drain);
|
|
295
|
+
return chain;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** `POST /v1/recommendations`; remembers the shown items for attribution. */
|
|
299
|
+
async function personalised(params, deadline) {
|
|
300
|
+
if (consent) reload();
|
|
301
|
+
const wire = toWire(history, now(), config);
|
|
302
|
+
const sentEmpty = wire.history.length === 0;
|
|
303
|
+
const response = await transport.request({
|
|
304
|
+
method: 'POST',
|
|
305
|
+
path: '/v1/recommendations',
|
|
306
|
+
timeoutMs: deadline,
|
|
307
|
+
body: compact({
|
|
308
|
+
user_id: userId ?? undefined,
|
|
309
|
+
anon_id: anonId ?? undefined,
|
|
310
|
+
placement: params.placement ?? defaultPlacement,
|
|
311
|
+
count: params.count,
|
|
312
|
+
tags_any: params.tagsAny,
|
|
313
|
+
tags_all: params.tagsAll,
|
|
314
|
+
exclude: withRejected(params.exclude, wire.rejected),
|
|
315
|
+
context_item_id: params.contextItemId,
|
|
316
|
+
history: wire.history,
|
|
317
|
+
recent: wire.recent,
|
|
318
|
+
discovery: params.discovery
|
|
319
|
+
})
|
|
320
|
+
});
|
|
321
|
+
if (typeof response?.recommendation_id !== 'string' || !Array.isArray(response.items)) {
|
|
322
|
+
throw new RecapiError('invalid_response', 'malformed recommendations response');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const recommendationId = response.recommendation_id;
|
|
326
|
+
const items = response.items.map(({ item_id: itemId, score, slot }) => ({ itemId, score, slot }));
|
|
327
|
+
mutate(() => {
|
|
328
|
+
const t = now();
|
|
329
|
+
for (const { itemId } of items) {
|
|
330
|
+
shown.delete(itemId);
|
|
331
|
+
shown.set(itemId, [recommendationId, t]);
|
|
332
|
+
}
|
|
333
|
+
while (shown.size > MAX_SHOWN_ITEMS) shown.delete(shown.keys().next().value);
|
|
334
|
+
// A snapshot is only trusted when we sent nothing, and merged once: a
|
|
335
|
+
// concurrent first call (two placements on one page) may have merged
|
|
336
|
+
// it already. An empty one leaves the next one trusted. The local
|
|
337
|
+
// state is merged, never overwritten: it holds the events tracked
|
|
338
|
+
// while the request was in flight, and rejections the server may not
|
|
339
|
+
// have yet.
|
|
340
|
+
if (sentEmpty && response.history && !(restored && !isEmpty(history))) {
|
|
341
|
+
history = mergeSnapshot(history, fromSnapshot(response.history, config), t, config);
|
|
342
|
+
restored = true;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
return { recommendationId, fallback: false, items };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Popular items standing in for a failed `recommend()`. The list keeps the
|
|
350
|
+
* requested length (a shorter one shifts the page) without the excluded
|
|
351
|
+
* items or the page's own product, so a few more are asked for. Nothing
|
|
352
|
+
* goes into the attribution map: there is no recommendation to credit.
|
|
353
|
+
*/
|
|
354
|
+
async function popularFallback({ count, tagsAny, tagsAll, exclude, contextItemId }, deadline) {
|
|
355
|
+
// 0 is the default count for recommendations, but /v1/popular refuses it.
|
|
356
|
+
const n = count || DEFAULT_COUNT;
|
|
357
|
+
const skip = new Set(withRejected(exclude, toWire(history, now(), config).rejected));
|
|
358
|
+
if (contextItemId !== undefined) skip.add(contextItemId);
|
|
359
|
+
const items = await fetchPopular({ tagsAny, tagsAll, count: Math.min(n + skip.size, MAX_COUNT) }, deadline);
|
|
360
|
+
return items
|
|
361
|
+
.filter(({ item_id: itemId }) => !skip.has(itemId))
|
|
362
|
+
.slice(0, n)
|
|
363
|
+
.map(({ item_id: itemId, score }) => ({ itemId, score, slot: 'backfill' }));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* `GET /v1/popular` for `popular()` and the fallback; `deadline` defaults
|
|
368
|
+
* to `timeoutMs`. A public key goes in the query string (CDN-cacheable, no
|
|
369
|
+
* CORS preflight); any other key in the header (Node with a secret key).
|
|
370
|
+
*/
|
|
371
|
+
async function fetchPopular({ tagsAny, tagsAll, count }, deadline) {
|
|
372
|
+
const response = await transport.request({
|
|
373
|
+
method: 'GET',
|
|
374
|
+
path: '/v1/popular',
|
|
375
|
+
auth: apiKey.startsWith(PUBLIC_KEY_PREFIX) ? 'query' : 'header',
|
|
376
|
+
timeoutMs: deadline,
|
|
377
|
+
query: { tags_any: tagsAny?.join(','), tags_all: tagsAll?.join(','), count }
|
|
378
|
+
});
|
|
379
|
+
if (!Array.isArray(response?.items)) {
|
|
380
|
+
throw new RecapiError('invalid_response', 'malformed popular response');
|
|
381
|
+
}
|
|
382
|
+
return response.items;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const onVisibility = () => {
|
|
386
|
+
if (globalThis.document?.visibilityState === 'hidden') flushOnHide();
|
|
387
|
+
};
|
|
388
|
+
globalThis.document?.addEventListener?.('visibilitychange', onVisibility);
|
|
389
|
+
globalThis.window?.addEventListener?.('pagehide', flushOnHide);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
/**
|
|
393
|
+
* Records an interaction: updates the local history immediately (so the
|
|
394
|
+
* next `recommend()` reflects it) and queues the event. Level 0 is an
|
|
395
|
+
* impression: sent for statistics, never added to the history.
|
|
396
|
+
*
|
|
397
|
+
* @param {string} itemId
|
|
398
|
+
* @param {number | 'view' | 'cart' | 'purchase' | 'reject'} level Integer
|
|
399
|
+
* -10–10, or the name of a level on the recommended scale (3, 5 and 10;
|
|
400
|
+
* `reject` is -10). A negative level is an explicit rejection ("not
|
|
401
|
+
* interested"): the item is never recommended to this user again until
|
|
402
|
+
* a positive event on it. Don't send skips or bounces as rejections.
|
|
403
|
+
* @param {{ recommendationId?: string | null }} [options] Overrides the
|
|
404
|
+
* automatic attribution. `null` (a fallback list's id) is no override.
|
|
405
|
+
*/
|
|
406
|
+
track(itemId, level, { recommendationId: explicit } = {}) {
|
|
407
|
+
// A fallback list has a null id, and a page passing it on must not
|
|
408
|
+
// send `null` or lose the item's earlier, real attribution.
|
|
409
|
+
const recommendationId = explicit ?? undefined;
|
|
410
|
+
if (!isId(itemId)) throw new RecapiError('invalid_request', 'itemId must be 1-256 bytes of UTF-8 without control characters');
|
|
411
|
+
if (typeof level === 'string' && Object.hasOwn(LEVEL_NAMES, level)) level = LEVEL_NAMES[level];
|
|
412
|
+
if (!Number.isInteger(level) || level < -10 || level > 10) {
|
|
413
|
+
throw new RecapiError(
|
|
414
|
+
'invalid_request',
|
|
415
|
+
"level must be an integer between -10 and 10, or 'view', 'cart', 'purchase' or 'reject'"
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const t = now();
|
|
419
|
+
let attributed = recommendationId;
|
|
420
|
+
mutate(() => {
|
|
421
|
+
if (level !== 0) history = applyEvent(history, { item: itemId, level, t }, config);
|
|
422
|
+
// An explicit id of the recommendation that showed the item is still
|
|
423
|
+
// the click on it; another id leaves the entry alone.
|
|
424
|
+
if (recommendationId === undefined || recommendationId === shown.get(itemId)?.[0]) {
|
|
425
|
+
attributed = attributedRecommendation(itemId, level, t) ?? recommendationId;
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
enqueue(compact({
|
|
429
|
+
item_id: itemId,
|
|
430
|
+
level,
|
|
431
|
+
user_id: userId ?? undefined,
|
|
432
|
+
anon_id: anonId ?? undefined,
|
|
433
|
+
recommendation_id: attributed,
|
|
434
|
+
timestamp: new Date(t).toISOString(),
|
|
435
|
+
event_id: uuid()
|
|
436
|
+
}));
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Fetches personalised recommendations. Fails fast (no retry, at most
|
|
441
|
+
* 1.5 s). When recapi is unreachable, slow or failing (network error,
|
|
442
|
+
* timeout, 5xx), resolves with popular items instead, with `fallback:
|
|
443
|
+
* true` and no recommendation id, and reports the error to `onError`.
|
|
444
|
+
* When the popular request fails too (recapi is down), the list is
|
|
445
|
+
* empty: hide the slot. Other errors still reject: a 4xx is an
|
|
446
|
+
* integration bug or a tenant limit (429), and a fallback would hide it.
|
|
447
|
+
*
|
|
448
|
+
* @param {{ count?: number, tagsAny?: string[], tagsAll?: string[], exclude?: string[],
|
|
449
|
+
* contextItemId?: string, placement?: string, discovery?: number }} [params]
|
|
450
|
+
* @returns {Promise<{ recommendationId: string | null, fallback: boolean,
|
|
451
|
+
* items: Array<{ itemId: string, score: number, slot: string }> }>}
|
|
452
|
+
*/
|
|
453
|
+
async recommend(params = {}) {
|
|
454
|
+
const deadline = Math.min(timeoutMs, RECOMMEND_TIMEOUT_MS);
|
|
455
|
+
try {
|
|
456
|
+
return await personalised(params, deadline);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (!outage(error)) throw error;
|
|
459
|
+
report(error);
|
|
460
|
+
let items = [];
|
|
461
|
+
try {
|
|
462
|
+
items = await popularFallback(params, deadline);
|
|
463
|
+
} catch (fallbackError) {
|
|
464
|
+
// recapi is down altogether. The page awaits recommend() without a
|
|
465
|
+
// catch (the README says it may), so an empty list it can hide
|
|
466
|
+
// beats a rejection that breaks it.
|
|
467
|
+
report(fallbackError);
|
|
468
|
+
}
|
|
469
|
+
return { recommendationId: null, fallback: true, items };
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Non-personalised popular items. A public key goes in the query string
|
|
475
|
+
* so the response is CDN-cacheable and the request needs no CORS
|
|
476
|
+
* preflight; a secret key stays in the `Authorization` header.
|
|
477
|
+
*
|
|
478
|
+
* @param {{ tagsAny?: string[], tagsAll?: string[], count?: number }} [params]
|
|
479
|
+
* @returns {Promise<{ items: Array<{ itemId: string, score: number }> }>}
|
|
480
|
+
*/
|
|
481
|
+
async popular(params = {}) {
|
|
482
|
+
const items = await fetchPopular(params);
|
|
483
|
+
return { items: items.map(({ item_id: itemId, score }) => ({ itemId, score })) };
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Attaches a user id to subsequent events and requests. The anonymous
|
|
488
|
+
* history is kept (it is this user's activity before login), but
|
|
489
|
+
* switching from one user id to another starts a fresh history.
|
|
490
|
+
*
|
|
491
|
+
* `options.history` takes a server snapshot (from
|
|
492
|
+
* `GET /v1/users/{id}/history`, fetched by your backend with the secret
|
|
493
|
+
* key) and merges it into the local history. Only pass a snapshot that
|
|
494
|
+
* does not already contain this device's events, or they count twice.
|
|
495
|
+
*
|
|
496
|
+
* @param {string} id
|
|
497
|
+
* @param {{ history?: ServerSnapshot }} [options]
|
|
498
|
+
*/
|
|
499
|
+
identify(id, { history: snapshot } = {}) {
|
|
500
|
+
if (!isId(id)) throw new RecapiError('invalid_request', 'userId must be 1-256 bytes of UTF-8 without control characters');
|
|
501
|
+
mutate(() => {
|
|
502
|
+
if (userId && userId !== id) {
|
|
503
|
+
history = emptyHistory();
|
|
504
|
+
shown = new Map();
|
|
505
|
+
restored = false;
|
|
506
|
+
}
|
|
507
|
+
userId = id;
|
|
508
|
+
if (snapshot) {
|
|
509
|
+
history = mergeSnapshot(history, fromSnapshot(snapshot, config), now(), config);
|
|
510
|
+
restored = true;
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Logout: forgets the user id, the history, attribution data and the
|
|
517
|
+
* anonymous id (a new one is generated). Already queued events keep the
|
|
518
|
+
* ids they were tracked with.
|
|
519
|
+
*/
|
|
520
|
+
reset() {
|
|
521
|
+
history = emptyHistory();
|
|
522
|
+
userId = null;
|
|
523
|
+
shown = new Map();
|
|
524
|
+
restored = false;
|
|
525
|
+
anonId = consent ? uuid() : null;
|
|
526
|
+
persist();
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Grants or withdraws consent for device storage (ePrivacy). Without
|
|
531
|
+
* consent nothing is read from or written to storage and no anonymous id
|
|
532
|
+
* exists; the history lives in memory for the current page only.
|
|
533
|
+
* Withdrawing deletes the stored data. Granting merges any previously
|
|
534
|
+
* stored history with the in-memory one.
|
|
535
|
+
*
|
|
536
|
+
* @param {boolean} value
|
|
537
|
+
*/
|
|
538
|
+
setConsent(value) {
|
|
539
|
+
if (value === consent) return;
|
|
540
|
+
consent = Boolean(value);
|
|
541
|
+
if (!consent) {
|
|
542
|
+
store.remove();
|
|
543
|
+
anonId = null;
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const saved = store.load();
|
|
547
|
+
if (saved) {
|
|
548
|
+
history = merge(saved.history, history, now(), config);
|
|
549
|
+
restored ||= saved.restored;
|
|
550
|
+
userId = userId ?? saved.userId;
|
|
551
|
+
anonId = saved.anonId;
|
|
552
|
+
for (const [item, entry] of saved.shown) if (!shown.has(item)) shown.set(item, entry);
|
|
553
|
+
}
|
|
554
|
+
anonId = anonId ?? uuid();
|
|
555
|
+
persist();
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
flush,
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The history exactly as the next recommendation request would send it;
|
|
562
|
+
* `rejected` goes into its `exclude`.
|
|
563
|
+
*
|
|
564
|
+
* @returns {{ history: Array<[string, number, number]>, recent: string[], rejected: string[] }}
|
|
565
|
+
*/
|
|
566
|
+
getHistory() {
|
|
567
|
+
if (consent) reload();
|
|
568
|
+
return toWire(history, now(), config);
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Removes listeners and timers, then flushes what is queued.
|
|
573
|
+
*
|
|
574
|
+
* @returns {Promise<void>}
|
|
575
|
+
*/
|
|
576
|
+
destroy() {
|
|
577
|
+
destroyed = true;
|
|
578
|
+
if (timer) clearTimeout(timer);
|
|
579
|
+
timer = null;
|
|
580
|
+
globalThis.document?.removeEventListener?.('visibilitychange', onVisibility);
|
|
581
|
+
globalThis.window?.removeEventListener?.('pagehide', flushOnHide);
|
|
582
|
+
return flush();
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Whether `recommend()` should fall back to popular items: recapi could not
|
|
589
|
+
* answer in time or failed on its side. A 429 is the tenant's own limit
|
|
590
|
+
* (`rate_limited` or `quota_exceeded`), not an outage, so it stays visible.
|
|
591
|
+
*
|
|
592
|
+
* @param {unknown} error
|
|
593
|
+
*/
|
|
594
|
+
function outage(error) {
|
|
595
|
+
return error instanceof RecapiError && (error.code === 'network' || error.code === 'timeout' || error.status >= 500);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Whether a shown-map entry still attributes events at `t`: within the
|
|
600
|
+
* display window until the item is clicked, then for the click window.
|
|
601
|
+
*
|
|
602
|
+
* @param {[string, number, number?]} entry [recommendation id, shown at, clicked at]
|
|
603
|
+
* @param {number} t
|
|
604
|
+
*/
|
|
605
|
+
function attributable([, shownAt, clickedAt], t) {
|
|
606
|
+
return clickedAt === undefined ? t - shownAt <= DISPLAY_WINDOW_MS : t - clickedAt <= CLICK_WINDOW_MS;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* @param {any} raw Parsed JSON of unknown shape.
|
|
611
|
+
* @returns {{ history: HistoryState, userId: string | null, anonId: string,
|
|
612
|
+
* shown: Array<[string, [string, number, number?]]>, restored: boolean } | null}
|
|
613
|
+
*/
|
|
614
|
+
function decodePersisted(raw) {
|
|
615
|
+
const history = decodeState(raw?.history);
|
|
616
|
+
if (!history || typeof raw.anonId !== 'string' || !Array.isArray(raw.shown)) return null;
|
|
617
|
+
if (raw.userId !== null && typeof raw.userId !== 'string') return null;
|
|
618
|
+
const shown = [];
|
|
619
|
+
for (const entry of raw.shown) {
|
|
620
|
+
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || typeof entry[1] !== 'string' ||
|
|
621
|
+
!Number.isFinite(entry[2]) || (entry.length > 3 && !Number.isFinite(entry[3]))) {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
// Entries without a click time are also the format of SDKs before the
|
|
625
|
+
// click window, so their storage keeps working.
|
|
626
|
+
if (isId(entry[0])) shown.push(/** @type {[string, [string, number, number?]]} */ ([entry[0], entry.slice(1, 4)]));
|
|
627
|
+
}
|
|
628
|
+
// Like the history's entries, a user id earlier SDKs let through but the
|
|
629
|
+
// API refuses is dropped, so requests work again (anonymously).
|
|
630
|
+
return { history, userId: isId(raw.userId) ? raw.userId : null, anonId: raw.anonId, shown, restored: raw.restored === true };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The caller's exclude list plus the rejected items, newest rejection first
|
|
635
|
+
* up to the API's limit. Unchanged when nothing was rejected.
|
|
636
|
+
*
|
|
637
|
+
* @param {string[] | undefined} exclude
|
|
638
|
+
* @param {string[]} rejected Oldest first.
|
|
639
|
+
*/
|
|
640
|
+
function withRejected(exclude, rejected) {
|
|
641
|
+
if (rejected.length === 0) return exclude;
|
|
642
|
+
const out = new Set(exclude);
|
|
643
|
+
for (let i = rejected.length - 1; i >= 0 && out.size < MAX_EXCLUDE; i--) out.add(rejected[i]);
|
|
644
|
+
return [...out];
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function compact(object) {
|
|
648
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function doNotTrackEnabled() {
|
|
652
|
+
const nav = globalThis.navigator;
|
|
653
|
+
return nav?.doNotTrack === '1' || nav?.globalPrivacyControl === true;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* RFC 4122 v4 id. `crypto.randomUUID` needs a secure context (and Node 19+
|
|
658
|
+
* as a global), hence the fallbacks.
|
|
659
|
+
*
|
|
660
|
+
* @returns {string}
|
|
661
|
+
*/
|
|
662
|
+
export function uuid() {
|
|
663
|
+
const c = globalThis.crypto;
|
|
664
|
+
if (typeof c?.randomUUID === 'function') {
|
|
665
|
+
try {
|
|
666
|
+
return c.randomUUID();
|
|
667
|
+
} catch {
|
|
668
|
+
// Insecure context: fall through.
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const bytes = new Uint8Array(16);
|
|
672
|
+
if (typeof c?.getRandomValues === 'function') c.getRandomValues(bytes);
|
|
673
|
+
else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
674
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
675
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
676
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
677
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
678
|
+
}
|