@recapi/client 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 recapi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,561 @@
1
- # Temporary Holding Version
1
+ # @recapi/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ JavaScript client for the recapi recommendation API, for browsers and
4
+ Node ≥ 18. Plain ES modules, no build step, no runtime dependencies.
5
+
6
+ The SDK keeps the user's interaction history on the device and sends it
7
+ with every recommendation request. Recommendations reflect the latest
8
+ click at once, and the server does not need to look anything up. The SDK
9
+ also batches events and attributes them to recommendations for you.
10
+
11
+ ## Install
12
+
13
+ On a site without a build step (a theme, plain HTML), load the SDK from
14
+ your recapi API with one tag. The API serves the files of `src/` as they
15
+ are, gzipped, and browsers cache them for a year:
16
+
17
+ ```html
18
+ <script type="module">
19
+ import { createClient } from 'https://api.example.com/sdk/0.1.2/index.js';
20
+
21
+ const recapi = createClient({ apiKey: 'rk_pub_…', baseUrl: 'https://api.example.com' });
22
+ recapi.track('item-42', 'view'); // and the calls of the quick start below
23
+ </script>
24
+ ```
25
+
26
+ A module script runs after the page is parsed and never blocks it. The
27
+ dashboard's quick start shows this tag with your API's address. A site
28
+ with a Content-Security-Policy allows that address in `script-src` and
29
+ `connect-src`. Each version's files never change: a later version gets a
30
+ new path, and an older path redirects to it, so a pasted tag keeps
31
+ working. New visitors then get the new version; a browser that cached the
32
+ older files may keep running them for up to a year.
33
+
34
+ With a bundler (Vite, webpack…), install the package instead:
35
+
36
+ ```sh
37
+ npm install @recapi/client
38
+ ```
39
+
40
+ ## Quick start
41
+
42
+ ### Browser
43
+
44
+ ```js
45
+ import { createClient } from '@recapi/client'; // or the /sdk/ URL above
46
+
47
+ const recapi = createClient({
48
+ apiKey: 'rk_pub_…', // public key only, never a secret key
49
+ baseUrl: 'https://api.example.com',
50
+ placement: 'homepage'
51
+ });
52
+
53
+ // The user opened an item: a product, an article, a video… (level 3).
54
+ recapi.track('item-42', 'view');
55
+
56
+ // Show recommendations. If recapi is slow or failing, recommend()
57
+ // resolves with popular items instead, and with an empty list if recapi is
58
+ // down altogether (hide the slot): no try/catch needed. Give the widget's
59
+ // container a CSS min-height so the page doesn't jump when the items arrive.
60
+ const { items } = await recapi.recommend({ count: 8, tagsAny: ['sports'] });
61
+ render(items); // [{ itemId, score, slot }]
62
+
63
+ // Later, the user clicks a recommended item and lands on its page (a
64
+ // view). No need to pass the recommendation id: the SDK remembers which
65
+ // recommendation showed this item, and later events on it (levels 5 and 10,
66
+ // such as read to the end or bought) are attributed too, for 24 hours after
67
+ // the click.
68
+ recapi.track('item-77', 'view');
69
+ ```
70
+
71
+ ### Shopify
72
+
73
+ Shopify's checkout runs no theme code, so a Shopify shop installs recapi
74
+ in two places, both shown with your address in the dashboard's quick start:
75
+
76
+ - [`shopify/theme.liquid`](shopify/theme.liquid), pasted in a *Custom
77
+ Liquid* section the theme editor adds to the footer group, so it runs on
78
+ every page; a theme update keeps editor sections but drops code edits
79
+ ([Updating themes](https://help.shopify.com/en/manual/online-store/themes/managing-themes/updating-themes)).
80
+ A theme whose footer takes no sections gets it before `</body>` in
81
+ `layout/theme.liquid` instead, again after each update. Its Liquid is
82
+ `product.handle` and `customer.id`, which a section reads as the layout
83
+ does, and it stays far below the setting's 50 KB. It identifies a
84
+ logged-in customer, tracks product views and fills any `<div id="recapi-recommendations">` with 4 product
85
+ cards it loads from Shopify's `/products/<handle>.js`. The product handle
86
+ is the item id. recapi has no catalog of a Shopify shop, so the snippet
87
+ asks for 6 and skips products that are sold out (`available: false`) or
88
+ deleted (404). All 6 count as one served list: CTR and lift are per list,
89
+ so they are unchanged. Each card shows the picture (resized by Shopify's
90
+ CDN with `?width=400`), the title and `price_min` formatted with
91
+ `Intl.NumberFormat` in `Shopify.currency.active` and the page's `lang`
92
+ (no price if either is missing). A default style makes them a product row
93
+ in the theme's colours and fonts; all its selectors sit inside
94
+ `:where(#recapi-recommendations …)`, which has no specificity, so any of
95
+ the theme's rules overrides it. It follows the shop's cookie banner through
96
+ Shopify's [Customer Privacy API](https://shopify.dev/docs/api/customer-privacy):
97
+ it keeps a history only with Analytics consent (the pixel's category;
98
+ where no consent is required Shopify reports it allowed) and fails closed
99
+ if the API doesn't load within 2 s. Without consent it stores nothing and
100
+ sends events without `anon_id`, and each card links to its product with
101
+ `?rec=<recommendation id>`, which the product page's view passes on, so a
102
+ click is still credited (see [Consent](#consent-and-eprivacy)). The page
103
+ then removes `rec` from its address (`history.replaceState`, other
104
+ parameters kept): recapi credits an explicit id to whoever sends it, so a
105
+ copied or shared link would credit strangers' visits and purchases to it.
106
+ - [`shopify/pixel.js`](shopify/pixel.js), a custom pixel (Settings →
107
+ Customer events): on `product_added_to_cart` it tracks the product as a
108
+ `cart` (level 5, the default conversion level, so a pilot's conversion
109
+ lift counts carts, not only the rarer orders), and on `checkout_completed`
110
+ each bought product as a purchase, with the handle from the product's URL.
111
+ A pixel runs in a sandbox with storage of its own, so it loads the shop
112
+ page's SDK state through Shopify's asynchronous `browser.localStorage`,
113
+ the way [Custom storage](#custom-storage) preloads an async store: the
114
+ event carries the visitor's ids and the recommendation that showed the
115
+ item. On the storefront the theme snippet writes that state too, so the
116
+ pixel writes it back only if the page didn't change it since the pixel
117
+ read it (its event is sent either way).
118
+
119
+ Both files pass the same [`project`](#createclientoptions), which the quick start fills
120
+ in for your environment, so they share the visitor's state whichever public
121
+ key each holds: keys are shown once, and a pixel added later with a new key
122
+ still credits each add to cart to the visitor and their recommendation.
123
+ Shopify's `product_added_to_cart` event carries only the cart line, not the
124
+ cart's attributes
125
+ ([docs](https://shopify.dev/docs/api/web-pixels-api/standard-events/product_added_to_cart)),
126
+ so a cart has no fallback like a purchase's below.
127
+
128
+ Shopify doesn't promise that the checkout's pixel can read the
129
+ storefront's storage, so the snippet also leaves the visitor's id and the
130
+ recommendations they clicked (the SDK's stored `shown` entries with a click
131
+ time, the 10 newest) in the cart, as a `_recapi` cart attribute: the leading
132
+ `_` hides it from the buyer, but the merchant sees it in the order's
133
+ details. The snippet writes it through `POST {root}cart/update.js` only with
134
+ consent (it watches what the SDK stores), and only when it changes: a
135
+ request per click on a card, none per page; a failed request is retried at
136
+ the next change of the state. Withdrawing consent empties it. When the
137
+ pixel's storage read comes back empty, `checkout_completed` rebuilds the
138
+ state from `event.data.checkout.attributes`, so the purchase still carries
139
+ the visitor and the recommendation; a stored state always wins, and a value
140
+ the SDK can't read is ignored. An item shown again in a later list loses its
141
+ click (as in storage), and orders that don't go through the online store's
142
+ cart (POS, draft orders, subscription renewals) carry no attribute.
143
+
144
+ `test/shopify.test.js` runs both files as the quick start shows them: a view,
145
+ a click on a recommendation, an add to cart, then a purchase in the checkout. A change to
146
+ them needs no new SDK version (they import the served one), but it changes
147
+ what every new Shopify shop pastes. The quick start fills in the current
148
+ version, your address and your project (`jssdk.Shopify`).
149
+
150
+ ### WooCommerce
151
+
152
+ [`woocommerce/recapi.php`](woocommerce/recapi.php) is a one-file WordPress
153
+ plugin (or a snippet for Code Snippets or WPCode) that prints the SDK on
154
+ every page from `wp_footer`. The product id is the item id. It tracks:
155
+
156
+ - a `view` on product pages, with `?rec=<recommendation id>` from a card's
157
+ link (then removed from the address, as on Shopify);
158
+ - a `cart` (level 5) once per add to cart: on the classic product page's
159
+ form submit (the page reloads, so it counts at once), or on jQuery's
160
+ `added_to_cart` (Ajax buttons) or the blocks' `wc-blocks_added_to_cart`
161
+ after a click on a button with `data-product_id`. The blocks event
162
+ carries no product, and a page with a mini-cart block fires both for one
163
+ click, so the click is the product and the first event uses it up;
164
+ - a `purchase` per order line on the order-received page (classic and block
165
+ checkout alike: `is_order_received_page()`), with the order key checked,
166
+ nothing for a failed or cancelled payment, and the order marked
167
+ (`_recapi_counted` meta) so a reload prints nothing. A logged-in buyer's
168
+ customer id is passed to `identify()` there.
169
+
170
+ The checkout is the shop's own origin, so the purchase reads the state the
171
+ product pages stored and is credited to the recommendation clicked: no
172
+ pixel and no cart attribute. Page caches serve product pages as stored
173
+ HTML, so the plugin prints only what is the same for every visitor (the
174
+ product id, the Store API's address); only the order-received page, which
175
+ caches skip, prints the order. The widget fills
176
+ `<div id="recapi-recommendations">` like the Shopify snippet's, loading the
177
+ 6 recommended products in one request to WooCommerce's public Store API
178
+ (`/wc/store/v1/products?include=…`) and skipping those not purchasable or
179
+ out of stock. Consent comes from the
180
+ [WP Consent API](https://wordpress.org/plugins/wp-consent-api/)
181
+ (`wp_has_consent('statistics')`, `wp_listen_for_consent_change`), which
182
+ banner plugins feed and which reports allowed where no banner manages
183
+ consent; without the API the plugin stores nothing, as the Shopify snippet
184
+ does when its consent API fails to load. It declares compatibility with
185
+ WooCommerce's order tables (HPOS) and checkout blocks when installed as a
186
+ plugin, and registers with the WP Consent API.
187
+
188
+ Have past orders? Download them from WooCommerce → recapi and upload the
189
+ file on Import data. The plugin writes the last 90 days of processing and
190
+ completed orders as the dashboard's `user_id,item_id,level,timestamp` CSV,
191
+ one `purchase` per order line, dated when the order was paid (else
192
+ placed). The buyer is the customer id `identify()` sends, a guest
193
+ `woocommerce-` + `wp_hash()` of their email (keyed with the site's salts,
194
+ so recapi never sees or can guess the email), or without an email
195
+ `woocommerce-order-<id>`. Only shop managers (`manage_woocommerce`) with
196
+ the page's nonce get the file, and it reads orders 200 at a time through
197
+ `wc_get_orders` (HPOS or not). The import skips rows already uploaded and
198
+ purchases the order-received page already sent.
199
+
200
+ `test/woocommerce.test.js` prints pages through PHP (`test/wordpress.php`
201
+ stands in for WordPress, run by `scripts/php.sh`) and runs the printed
202
+ script against the SDK. The quick start fills in the current version and
203
+ your address (`jssdk.WooCommerce`).
204
+
205
+ ### Node
206
+
207
+ ```js
208
+ import { createClient, memoryStorage } from '@recapi/client';
209
+
210
+ const recapi = createClient({
211
+ apiKey: process.env.RECAPI_KEY,
212
+ baseUrl: 'https://api.example.com',
213
+ storage: memoryStorage() // the default in Node anyway
214
+ });
215
+
216
+ recapi.identify('cust-881');
217
+ recapi.track('item-42', 10); // the action that matters most: a purchase, a subscription…
218
+ await recapi.destroy(); // flushes queued events before exit
219
+ ```
220
+
221
+ Timers do not keep a Node process alive. Call `await client.flush()` or
222
+ `await client.destroy()` before exiting, or queued events are lost.
223
+
224
+ ## API
225
+
226
+ ### `createClient(options)`
227
+
228
+ | Option | Default | Notes |
229
+ |---|---|---|
230
+ | `apiKey` | required | `rk_pub_…` in browsers |
231
+ | `baseUrl` | required | API origin |
232
+ | `placement` | – | default placement for `recommend()` |
233
+ | `storage` | localStorage, else memory | any `{ getItem, setItem, removeItem }` synchronous store |
234
+ | `project` | first 16 chars of `apiKey` | storage namespace: key `recapi:v1:<project>` |
235
+ | `flushIntervalMs` | `2000` | delay before queued events are sent |
236
+ | `maxBatch` | `100` | events per request (max 1000); a full queue flushes at once |
237
+ | `timeoutMs` | `5000` | per-request timeout; `recommend()` waits 1.5 s at most |
238
+ | `historyConfig` | spec defaults | `{ halfLifeSeconds, topK, recentSize }`; must match the server |
239
+ | `consent` | `true` | storage consent, see [Consent](#consent-and-eprivacy) |
240
+ | `respectDoNotTrack` | `false` | start without consent when DNT or Global Privacy Control is on |
241
+ | `onError` | – | gets errors from background work (flushes, storage, rejected events) and those `recommend()` fell back from |
242
+ | `now`, `fetch`, `sleep` | `Date.now`, global `fetch`, `setTimeout` | for tests |
243
+
244
+ ### Client methods
245
+
246
+ - **`track(itemId, level, { recommendationId? })`**: `level` is any integer
247
+ 0–10 of the [recommended scale](../../docs/api.md#interaction-levels) (3
248
+ opened, 5 strong interest, 10 the action that matters most), or one of its
249
+ shop names `'view'`, `'cart'` and `'purchase'` (3, 5 and 10). `'reject'`
250
+ (or any level -1 to -10) is an explicit "not interested": the item is
251
+ never recommended to this user again until a positive event on it. Use
252
+ it for a rejection control only, never for skips or bounces
253
+ ([Rejections](../../docs/api.md#rejections)). Updates the
254
+ local history at once and queues the event with `anon_id`, `user_id`, a
255
+ timestamp and a fresh `event_id` (the idempotency
256
+ key, reused on retries). Level 0 means an impression: it is sent but does
257
+ not change the history. Throws `RecapiError('invalid_request')` on bad
258
+ arguments, with the API's rules: an item id is 1–256 bytes of UTF-8
259
+ (128 characters of Cyrillic or accented Latin, 85 of CJK) without
260
+ control characters. A stored history entry whose id breaks the rule,
261
+ left by an earlier SDK version, is dropped when the page loads.
262
+ - **`recommend({ count, tagsAny, tagsAll, exclude, contextItemId, placement, discovery })`**
263
+ resolves to `{ recommendationId, fallback, items: [{ itemId, score, slot }] }`.
264
+ It never retries and waits at most 1.5 s (less if `timeoutMs` is lower):
265
+ a late recommendation is worthless. See [Fallback](#fallback) for what
266
+ happens when the call fails. The user's rejected items (the newest 200)
267
+ are added to `exclude`, after yours, up to the API's limit of 500. The SDK does not send `explain`, which needs
268
+ a secret key: call the API from your backend for that.
269
+ - **`popular({ tagsAny, tagsAll, count })`** resolves to `{ items: [{ itemId, score }] }`.
270
+ A public key goes in `?key=`, so the response can be cached by a CDN and
271
+ the request needs no CORS preflight. A secret key (Node) goes in the
272
+ `Authorization` header, because the API refuses a secret key in a URL.
273
+ The [Fallback](#fallback) sends the key the same way.
274
+ - **`identify(userId, { history? })`**: see [Login and logout](#login-and-logout).
275
+ - **`reset()`**: logout. Clears the user id, history and attribution data,
276
+ and starts a new anonymous id. Events already queued keep the ids they
277
+ were tracked with.
278
+ - **`setConsent(boolean)`**: grants or withdraws storage consent.
279
+ - **`flush()`**: sends queued events now. Never rejects. Errors go to
280
+ `onError`, and batches that can still succeed stay queued.
281
+ - **`getHistory()`** returns `{ history, recent, rejected }` exactly as the
282
+ next request would send it (`rejected` goes into `exclude`). Use it for
283
+ debugging.
284
+ - **`destroy()`** removes listeners and timers, then flushes.
285
+
286
+ ### Errors
287
+
288
+ Every failure is a `RecapiError` with `code`, `status` and `message`. The
289
+ API error body supplies these when present. Client-side codes are
290
+ `network`, `timeout`, `invalid_response`, `event_rejected` (some events in
291
+ a 202 were rejected; see `details`) and `queue_overflow`. `status` is 0
292
+ when no HTTP response was received. Use `error.retryable` to tell
293
+ transient failures apart. `recommend()` resolves with popular items
294
+ instead of rejecting on `network`, `timeout` and 5xx errors, and reports
295
+ the error to `onError` (see [Fallback](#fallback)).
296
+
297
+ ### Fallback
298
+
299
+ When recapi does not answer `recommend()` within 1.5 s, cannot be
300
+ reached, or fails with a 5xx, the SDK asks `GET /v1/popular` with the same
301
+ `tagsAny`, `tagsAll` and `count` (also within 1.5 s) and resolves with
302
+ `{ recommendationId: null, fallback: true, items }`. The items have slot
303
+ `backfill`. The items in `exclude` and the `contextItemId` item are left
304
+ out, and the list still has `count` items when the catalog has enough, so
305
+ the widget keeps its size. Nothing is recorded for attribution: events on these items carry no
306
+ `recommendation_id`, so clicks and conversions on the dashboard count only
307
+ real recommendations. The original error goes to `onError`.
308
+
309
+ When `GET /v1/popular` fails too (recapi is down altogether), `recommend()`
310
+ resolves with `{ recommendationId: null, fallback: true, items: [] }`, and
311
+ both errors go to `onError`: hide the slot when `items` is empty. The page
312
+ never needs a `catch` for an outage.
313
+
314
+ `recommend()` still rejects, with no fallback, when:
315
+
316
+ - the request is wrong or the key is not allowed: 400, 401, 403 (a
317
+ suspended tenant) and 404. These are integration bugs, so they stay
318
+ visible;
319
+ - the tenant hit its limits: 429 `rate_limited` and 429 `quota_exceeded`.
320
+ These are limits to raise, not outages, and a fallback would hide them;
321
+ - the response is malformed (`invalid_response`), for example a `baseUrl`
322
+ that points at your own site.
323
+
324
+ ## How it works
325
+
326
+ ### History
327
+
328
+ The history follows [docs/history-spec.md](../../docs/history-spec.md)
329
+ exactly, and the shared vectors in `spec/history_vectors.json` check it.
330
+ Each item has a score that decays with a 7-day half-life. The SDK keeps
331
+ the 50 heaviest items plus an ordered list of the 20 most recent distinct
332
+ items. Requests carry `history: [[item_id, weight, age_s], …]` and
333
+ `recent: [item_id, …]`. The pure functions (`applyEvent`, `toWire`,
334
+ `fromSnapshot`, `merge`, …) are exported for server-side rendering and
335
+ tests.
336
+
337
+ Several tabs share one history: before each change, the SDK reads the
338
+ latest stored state and writes the result back. If a write fails (quota
339
+ exceeded, Safari private mode), the SDK keeps working from memory for the
340
+ rest of the page.
341
+
342
+ **Server snapshot.** When a request has an empty history and a `user_id`,
343
+ the server scores it with its stored history (unless the tenant turned the
344
+ server-side history off in the dashboard settings). It returns that history only
345
+ to secret-key callers, because the history is personal data. Browsers using
346
+ a public key therefore get the snapshot through your backend and
347
+ `identify(userId, { history })`. When a snapshot does arrive (Node with a
348
+ secret key), the SDK stores it only if it sent an empty history, so newer
349
+ local data is never overwritten. Events tracked while that request was in
350
+ flight are merged with the snapshot, not replaced by it, and so are the
351
+ rejections the server may not have stored yet (see the merge rule below).
352
+ A snapshot is merged once, because merging adds scores up: when two first
353
+ `recommend()` calls run together (two placements on one page), both get it
354
+ back and only one copy is kept. A reply's snapshot after
355
+ `identify(userId, { history })` is skipped the same way. An empty snapshot
356
+ (a user with nothing stored yet) leaves the next one trusted, and a new
357
+ user id or `reset()` starts over.
358
+
359
+ ### Attribution
360
+
361
+ `recommend()` remembers which recommendation showed each returned item.
362
+ For 30 minutes after the display, any `track()` on that item carries that
363
+ `recommendation_id` (impressions included), and the first one above
364
+ level 0 is the click on it. After a click, the item's later events (an add
365
+ to cart, a read to the end, a purchase) carry the id for 24 hours after the
366
+ click. An item nobody
367
+ clicked within 30 minutes was found some other way, and its events are no
368
+ longer attributed. The dashboard counts a conversion only on an item
369
+ clicked first (a level-5 event straight from the widget, such as an add to
370
+ cart, counts as the click).
371
+
372
+ This survives page loads when the SDK may use storage (see
373
+ [Consent](#consent-and-eprivacy)). The map keeps at most 500 items, and
374
+ the oldest are dropped first. An explicit `recommendationId` always wins;
375
+ when it is the id of the recommendation that showed the item, it is the
376
+ click on the item too.
377
+
378
+ ### Event delivery
379
+
380
+ Events are sent in batches every `flushIntervalMs`, or as soon as
381
+ `maxBatch` events are queued. Failed batches are retried with
382
+ exponential backoff and jitter (3 retries, `Retry-After` honoured, at
383
+ most 8 s between tries) on 429 `rate_limited`, 5xx, network errors and
384
+ timeouts. If retries run out, the batch goes back in the queue and later
385
+ flushes back off up to 60 s. The queue holds at most 1000 events, and the
386
+ oldest are dropped first.
387
+
388
+ Any other failure drops the whole batch and reports it to `onError`:
389
+ 400 `invalid_request`, 401, 403 (suspended tenant), 413, and 429
390
+ `quota_exceeded`. The quota is checked per batch, so once the monthly
391
+ event quota or spend cap is reached, events are lost until it resets or
392
+ the plan changes. A 202 with some `rejected` events keeps the accepted
393
+ ones, and the rejected ones are reported as `event_rejected`.
394
+
395
+ When the page is hidden (`visibilitychange` → `hidden`, or `pagehide`),
396
+ the SDK sends the queue at once using `fetch` with `keepalive: true`.
397
+ It does not use `navigator.sendBeacon`, because a beacon cannot send the
398
+ `Authorization` header and the API accepts `?key=` only on
399
+ `GET /v1/popular`. A keepalive fetch also survives page unload, but the
400
+ browser allows at most 64 KiB of keepalive bodies in flight per page, all
401
+ requests together. The SDK marks a request keepalive only up to 60,000
402
+ bytes (about 250 typical events). A default batch of 100 events is about
403
+ 24 KB, so about two full batches fit: keep `maxBatch` at 100 in
404
+ browsers. The queue is flushed every `flushIntervalMs` (2 s), so it usually
405
+ holds a few events. After a long network outage it can hold
406
+ up to 1000 events. The queue lives in memory, so what does not fit in the
407
+ budget is lost when the page closes. Every events request uses keepalive,
408
+ so a batch already in flight when the user navigates away still arrives.
409
+
410
+ ### Login and logout
411
+
412
+ - `identify(userId)` keeps the anonymous history, because it is the same
413
+ person before login. Switching from one user id to another starts a new
414
+ history and forgets which recommendations showed which items. A user id
415
+ follows the item id rule (1–256 bytes of UTF-8, no control characters);
416
+ `identify()` throws `RecapiError('invalid_request')` otherwise.
417
+ - `identify(userId)` alone does not bring the user's history to a new
418
+ device. The server scores a request with its stored history only when
419
+ the request has no history at all. That is true on a fresh device until
420
+ the first `track()`. After that, only the local history counts. To carry
421
+ the full history over, pass a server snapshot, as below.
422
+ - `identify(userId, { history: snapshot })` merges a server snapshot into
423
+ the local history. Your backend gets the snapshot with the secret key
424
+ from `GET /v1/users/{id}/history`. Merging adds up the decayed scores
425
+ item by item. Use it only when the snapshot does not already contain
426
+ this device's events, typically right after login. Otherwise those
427
+ events count twice.
428
+ - Merge rule for the recent lists: take the union of both lists and order
429
+ it by interaction time, oldest first. An item in both lists takes its
430
+ latest time. Then keep the last 20.
431
+ - Merge rule for rejections: an item this device rejected stays rejected,
432
+ even if the snapshot scores it. The snapshot only says its events came
433
+ before `as_of`, and the rejection may not have reached the server yet.
434
+ A rejection in the snapshot wins over this device's events before
435
+ `as_of`; a later event on the item clears it.
436
+ - `reset()` on logout.
437
+
438
+ ### Consent and ePrivacy
439
+
440
+ Under the EU ePrivacy Directive (art. 5(3)), storing data on the user's
441
+ device, or reading it back, generally needs prior consent unless it is
442
+ strictly necessary. A recommendation history usually is not. Wire the SDK
443
+ to your consent banner:
444
+
445
+ ```js
446
+ const recapi = createClient({ apiKey, baseUrl, consent: cmp.hasConsent('personalisation') });
447
+ cmp.onChange((granted) => recapi.setConsent(granted));
448
+ ```
449
+
450
+ Without consent, the SDK never reads or writes storage and creates no
451
+ anonymous id. The history and the attribution map live in memory for the
452
+ current page only. A single-page app keeps attribution. On a multi-page
453
+ site, a click that loads the item's page is not attributed unless you
454
+ pass the id yourself, for example in the link:
455
+
456
+ ```js
457
+ // On the listing page: link each item with its recommendation id
458
+ // (null when the list is the popular fallback: then leave it out of the link).
459
+ const { recommendationId, items } = await recapi.recommend({ count: 8 });
460
+ // href = recommendationId ? `/p/${item.itemId}?rec=${recommendationId}` : `/p/${item.itemId}`
461
+
462
+ // On the item's page:
463
+ const url = new URL(location.href);
464
+ const rec = url.searchParams.get('rec');
465
+ recapi.track('item-77', 'view', { recommendationId: rec }); // null: automatic attribution
466
+ // Then drop it, so a copied or shared address doesn't credit strangers to that list.
467
+ if (rec !== null) {
468
+ url.searchParams.delete('rec');
469
+ history.replaceState(history.state, '', url);
470
+ }
471
+ ```
472
+
473
+ Events are still sent, without `anon_id`. Don't call `track()` if you
474
+ need no network at all. Withdrawing consent deletes the stored data.
475
+ Granting it merges the in-memory history into storage.
476
+ `respectDoNotTrack: true` starts without consent when the browser sends
477
+ DNT or Global Privacy Control.
478
+
479
+ ## Custom storage
480
+
481
+ Any synchronous `{ getItem, setItem, removeItem }` works. Every call is
482
+ guarded, so a throwing adapter never breaks the page. For an async store
483
+ such as React Native AsyncStorage, preload the value into memory before
484
+ creating the client, and write it back asynchronously:
485
+
486
+ ```js
487
+ const key = 'recapi:v1:myproject';
488
+ const cache = new Map([[key, await AsyncStorage.getItem(key)]]);
489
+ const storage = {
490
+ getItem: (k) => cache.get(k) ?? null,
491
+ setItem: (k, v) => { cache.set(k, v); AsyncStorage.setItem(k, v); },
492
+ removeItem: (k) => { cache.delete(k); AsyncStorage.removeItem(k); }
493
+ };
494
+ createClient({ apiKey, baseUrl, project: 'myproject', storage });
495
+ ```
496
+
497
+ Corrupt or unrecognised stored data is discarded. Bumping the `v1` in the
498
+ key on a format change makes old data invisible.
499
+
500
+ ## Size budget
501
+
502
+ Budget: **6 KB gzip** after minification. The sources ship unminified with
503
+ full JSDoc: 53 KB raw in five files, 18 KB as the API serves them (gzip -9),
504
+ and the browser caches them for a year. With comments stripped and no
505
+ minification they are 7.2 KB gzip. A bundler's minifier should bring
506
+ that under the budget, but this has not been measured: the repo has no
507
+ minifier, to keep zero dev dependencies.
508
+
509
+ ## Changes
510
+
511
+ - 2026-10-26: version 0.1.2. When `GET /v1/popular` fails too (recapi is
512
+ down altogether), `recommend()` resolves with an empty list
513
+ (`fallback: true`) instead of rejecting, and reports both errors to
514
+ `onError` ([Fallback](#fallback)).
515
+ - 2026-10-26: version 0.1.1. A server snapshot is merged once: two first
516
+ `recommend()` calls running together (Node with a secret key) no longer
517
+ double the user's stored scores ([Server snapshot](#history)).
518
+ - 2026-10-26: version 0.1.0 is served by the API at
519
+ `/sdk/0.1.0/<file>.js`, so a site installs it with one script tag
520
+ ([Install](#install)).
521
+ - 2026-10-25: `recommend()` falls back to popular items by itself on a
522
+ timeout (now 1.5 s), a network error or a 5xx, and resolves with
523
+ `fallback: true` and `recommendationId: null`
524
+ ([Fallback](#fallback)). A `catch` that called `popular()` no longer
525
+ runs for these errors and can go; 4xx and 429 still reject. `popular()`
526
+ sends a secret key in the `Authorization` header instead of `?key=`, and
527
+ `track()` treats `recommendationId: null` like no id.
528
+
529
+ ## Development
530
+
531
+ ```sh
532
+ npm test # node --test, zero dev dependencies
533
+ ```
534
+
535
+ The API embeds `src/*.js` (module `recapi/sdkjs`, `embed.go`) and serves
536
+ them at `/sdk/<version>/`, where browsers cache them for a year. So a
537
+ change to `src/` needs a new version: bump `version` in `package.json`,
538
+ add the entry the failing Go test `TestSDKChangesNeedANewVersion` prints
539
+ to `published.json` (the digest is `sha256sum src/*.js | sha256sum`, then
540
+ each file's exports), and change the `/sdk/<version>/` paths in this
541
+ README and `docs/api.md` (the quick start follows by itself). Never edit
542
+ an existing entry of `published.json`.
543
+ Once production runs that version, the release service publishes it to
544
+ npm as `@recapi/client` ([deployment.md](../../docs/deployment.md#publishing-the-javascript-library)):
545
+ nobody runs `npm publish` by hand.
546
+
547
+ Every older path, whatever its version number, redirects to the new
548
+ files, and a browser that still caches some of the older files (after a
549
+ partial cache eviction, or a page load that spans a deploy) runs them next
550
+ to the new ones: an older file then imports from a newer one. So options
551
+ and every file's named exports, internal ones too, only grow, like the
552
+ HTTP API: an export keeps its name and accepts what older callers pass,
553
+ and a rename keeps the old name as an alias (`export { isId as isItemId }`).
554
+ `TestSDKKeepsPublishedExports` fails when a published export or file is
555
+ gone; parameters need a careful review, as no test sees them. Two copies
556
+ of a file can then load side by side, which is why the files keep no
557
+ mutable module-level state.
558
+
559
+ ## License
560
+
561
+ MIT ([LICENSE](LICENSE)).