@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/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
+ }