@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 +21 -0
- package/README.md +560 -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/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
|
-
#
|
|
1
|
+
# @recapi/client
|
|
2
2
|
|
|
3
|
-
|
|
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)).
|