@recapi/client 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -16,7 +16,7 @@ are, gzipped, and browsers cache them for a year:
16
16
 
17
17
  ```html
18
18
  <script type="module">
19
- import { createClient } from 'https://api.example.com/sdk/0.4.0/index.js';
19
+ import { createClient } from 'https://api.example.com/sdk/0.5.1/index.js';
20
20
 
21
21
  const recapi = createClient({ apiKey: 'rk_pub_…', baseUrl: 'https://api.example.com' });
22
22
  recapi.track('item-42', 'view'); // and the calls of the quick start below
@@ -103,7 +103,15 @@ in two places, both shown with your address in the dashboard's quick start:
103
103
  so they are unchanged. Each card shows the picture (resized by Shopify's
104
104
  CDN with `?width=400`), the title and `price_min` formatted with
105
105
  `Intl.NumberFormat` in `Shopify.currency.active` and the page's `lang`
106
- (no price if either is missing). A default style makes them a product row
106
+ (no price if either is missing). An item whose
107
+ [metadata](../../docs/api.md#item-metadata) has a `name` and an http(s)
108
+ `url` skips that lookup: its card is drawn from `name`, `url`, `picture`
109
+ (http(s) only) and `price` (text shown as it is, or a number in the
110
+ shop's currency, `{{ shop.currency }}`, shown only to visitors browsing in
111
+ it; others get Shopify's converted price from the lookup). A row whose
112
+ items all carry metadata then costs one request instead of seven. The
113
+ snippet can't send metadata: the shop sends it with `PUT /v1/items`, and
114
+ marks a sold-out product inactive there, since no lookup checks it. A default style makes them a product row
107
115
  in the theme's colours and fonts; all its selectors sit inside
108
116
  `:where(#recapi-recommendations …)`, which has no specificity, so any of
109
117
  the theme's rules overrides it. It follows the shop's cookie banner through
@@ -224,7 +232,11 @@ shortcode shows none unless given one: `[recapi heading="You may also like"]`.
224
232
  The widget fills it like the Shopify snippet's, once it nears the screen,
225
233
  loading the 6 recommended products in one request to WooCommerce's public Store API
226
234
  (`/wc/store/v1/products?include=…`) and skipping those not purchasable or
227
- out of stock. Consent comes from the
235
+ out of stock. Items whose [metadata](../../docs/api.md#item-metadata)
236
+ has a `name` and an http(s) `url` are left out of that request and drawn
237
+ from it, as in the Shopify snippet (a number `price` is in the shop's
238
+ currency, `woocommerce_currency`, and shown only while the visitor browses
239
+ in it), so a row whose items all carry metadata needs no Store API call. Consent comes from the
228
240
  [WP Consent API](https://wordpress.org/plugins/wp-consent-api/)
229
241
  (`wp_has_consent('statistics')`, `wp_listen_for_consent_change`), which
230
242
  banner plugins feed and which reports allowed where no banner manages
@@ -233,6 +245,31 @@ does when its consent API fails to load. It declares compatibility with
233
245
  WooCommerce's order tables (HPOS) and checkout blocks when installed as a
234
246
  plugin, and registers with the WP Consent API.
235
247
 
248
+ With a secret key pasted on WooCommerce → recapi (stored as the
249
+ `recapi_secret_key` option, not autoloaded, and never printed back), the
250
+ plugin describes the shop's products itself. Each product changed in a
251
+ request (`woocommerce_new_product`, `woocommerce_update_product`, a stock
252
+ status change, a variation's change as its parent's, a status change such as
253
+ trashing or a scheduled product going live, a deletion) is read again at
254
+ the end of the request and sent once the page is out, as one
255
+ `PUT /v1/items/{item_id}` or one `POST /v1/items:batch`: one try of at most
256
+ 2 s through WordPress's HTTP API, a failure logged to WooCommerce → Status →
257
+ Logs (source `recapi`), so a recapi outage never fails a save. A WP-Cron
258
+ event (`recapi_sync`, daily, first run when the key is saved) sends the
259
+ whole catalog, 200 products read and 1,000 sent at a time, oldest first,
260
+ with three tries per batch against an outage; the page shows when it last
261
+ ran, how many products it sent and why any were refused. Every item is
262
+ complete: `active` only while the product is published, not hidden from the
263
+ catalog, purchasable and in stock (the Store API cards' rule), its category
264
+ names as `tags` (not "Uncategorized"), its creation date as `published_at`,
265
+ and `name`, `url`, `picture` (the `woocommerce_thumbnail` size) and `price`
266
+ (as the shop displays it, in its currency) as `metadata`, shrunk below
267
+ 2,048 bytes as the Magento module does (picture first, then the name). The
268
+ row then draws every card from it, with no Store API call. Only items that
269
+ changed are billed. The plugin uses WordPress's HTTP API rather than the PHP
270
+ library so it stays one file (a snippet holds it) and follows the site's
271
+ proxy settings.
272
+
236
273
  Have past orders? Download them from WooCommerce → recapi and upload the
237
274
  file on Import data. The plugin writes the last 90 days of processing and
238
275
  completed orders as the dashboard's `user_id,item_id,level,timestamp` CSV,
@@ -256,7 +293,10 @@ The Magento 2 module ([`sdk/php/magento`](../php/magento/README.md)) prints
256
293
  the SDK on every storefront page for views and add-to-carts, and sends
257
294
  orders from the server with the PHP library. With consent, its script
258
295
  mirrors the visitor's id and clicked recommendations into a first-party
259
- cookie so the server's purchases are credited like the SDK's own.
296
+ cookie so the server's purchases are credited like the SDK's own. On product
297
+ pages it fills recapi's row, drawing each card from the item's `metadata`
298
+ or, without it, from the store; the row is printed with the store's own
299
+ products, which stay while recapi has none.
260
300
 
261
301
  ### Node
262
302
 
@@ -310,7 +350,7 @@ for (const item of items) {
310
350
  | `timeoutMs` | `5000` | per-request timeout; `recommend()` waits 1.5 s at most |
311
351
  | `historyConfig` | spec defaults | `{ halfLifeSeconds, topK, recentSize }`; must match the server |
312
352
  | `consent` | `true` | storage consent, see [Consent](#consent-and-eprivacy) |
313
- | `respectDoNotTrack` | `false` | start without consent when DNT or Global Privacy Control is on |
353
+ | `respectDoNotTrack` | `true` | when DNT or Global Privacy Control is on: store nothing, send no events, see [Do Not Track](#do-not-track) |
314
354
  | `onError` | – | gets errors from background work (flushes, storage, rejected events) and those `recommend()` fell back from |
315
355
  | `now`, `fetch`, `sleep` | `Date.now`, global `fetch`, `setTimeout` | for tests |
316
356
 
@@ -501,7 +541,9 @@ so a batch already in flight when the user navigates away still arrives.
501
541
  from `GET /v1/users/{id}/history`. Merging adds up the decayed scores
502
542
  item by item. Use it only when the snapshot does not already contain
503
543
  this device's events, typically right after login. Otherwise those
504
- events count twice.
544
+ events count twice. The snapshot also holds the anonymous browsing of
545
+ the devices the user logged in on before: on such a device, what was
546
+ browsed there since would count twice.
505
547
  - Merge rule for the recent lists: take the union of both lists and order
506
548
  it by interaction time, oldest first. An item in both lists takes its
507
549
  latest time. Then keep the last 20.
@@ -550,8 +592,21 @@ if (rec !== null) {
550
592
  Events are still sent, without `anon_id`. Don't call `track()` if you
551
593
  need no network at all. Withdrawing consent deletes the stored data.
552
594
  Granting it merges the in-memory history into storage.
553
- `respectDoNotTrack: true` starts without consent when the browser sends
554
- DNT or Global Privacy Control.
595
+
596
+ ### Do Not Track
597
+
598
+ When the browser sends Do Not Track (`navigator.doNotTrack === '1'`) or
599
+ Global Privacy Control (`navigator.globalPrivacyControl === true`), the
600
+ SDK follows nobody: it reads and writes no storage, `track()` and
601
+ `identify()` do nothing, and `setConsent(true)` doesn't lift it (the
602
+ browser's setting is the visitor's standing answer). `recommend()` and
603
+ `popular()` still work: the request carries no visitor id and no history,
604
+ so the list is popular, trending or similar items (with `contextItemId`).
605
+ The Shopify and WooCommerce snippets and Magento's storefront script
606
+ build on the SDK and behave the same (Magento's order observer runs on the
607
+ server and still sends the purchase). A site with its own legal basis for tracking those visitors
608
+ passes `respectDoNotTrack: false` and gets the behaviour of 0.4.0 and
609
+ earlier.
555
610
 
556
611
  ## Custom storage
557
612
 
@@ -585,6 +640,16 @@ minifier, to keep zero dev dependencies.
585
640
 
586
641
  ## Changes
587
642
 
643
+ - 2026-11-01: version 0.5.1. Without consent, and under Do Not Track, the
644
+ SDK no longer touches `localStorage` at all: before, it wrote and removed a
645
+ probe key when the client was created, to check that storage works. Now
646
+ that check waits for the first read or write, which only consent allows.
647
+ - 2026-11-01: version 0.5.0. Do Not Track and Global Privacy Control are
648
+ respected by default: the SDK then stores nothing and sends no events or
649
+ visitor ids, and still asks for recommendations
650
+ ([Do Not Track](#do-not-track)). Before, `respectDoNotTrack` was off by
651
+ default and only stopped storage. Snippets pasted with an older version
652
+ get it too: older versions' paths redirect to the current one.
588
653
  - 2026-10-31: version 0.4.0. Recommended and popular items carry `why`,
589
654
  a reason code you can turn into a label for visitors
590
655
  ([Showing why an item is listed](#showing-why-an-item-is-listed)).
package/index.d.ts CHANGED
@@ -78,7 +78,10 @@ export interface ClientOptions {
78
78
  historyConfig?: Partial<HistoryConfig>;
79
79
  /** Initial storage consent, default `true`. */
80
80
  consent?: boolean;
81
- /** Start without consent when Do Not Track / Global Privacy Control is on. */
81
+ /**
82
+ * Default `true`: when Do Not Track / Global Privacy Control is on, store
83
+ * nothing, send no events and no visitor ids; recommendations still work.
84
+ */
82
85
  respectDoNotTrack?: boolean;
83
86
  /** Receives errors from background work (flushes, storage) and those `recommend()` fell back from. */
84
87
  onError?: (error: unknown) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recapi/client",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Browser and Node client for the recapi recommendation API: local decayed history, event batching, attribution.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/client.js CHANGED
@@ -80,8 +80,9 @@ const PUBLIC_KEY_PREFIX = 'rk_pub_';
80
80
  * @property {number} [timeoutMs] Per-request timeout.
81
81
  * @property {Partial<HistoryConfig>} [historyConfig]
82
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.
83
+ * @property {boolean} [respectDoNotTrack] When the browser sends Do Not
84
+ * Track or Global Privacy Control (default `true`): store nothing, send no
85
+ * events and no visitor ids; recommendations are still asked for.
85
86
  * @property {(error: unknown) => void} [onError] Receives background errors,
86
87
  * and the errors `recommend()` fell back from.
87
88
  * @property {() => number} [now] Clock in unix ms.
@@ -120,15 +121,29 @@ export function createClient(options) {
120
121
  }
121
122
  };
122
123
  const transport = createTransport({ baseUrl, apiKey, fetch: fetchImpl, timeoutMs });
124
+ // The default adapter probes localStorage with a write, so it is picked at
125
+ // the first storage access: never before consent, nor under Do Not Track.
126
+ let adapter = options.storage;
127
+ const storage = () => (adapter ??= defaultStorage(report));
123
128
  const store = createStore(
124
- options.storage ?? defaultStorage(report),
129
+ {
130
+ getItem: (key) => storage().getItem(key),
131
+ setItem: (key, value) => storage().setItem(key, value),
132
+ removeItem: (key) => storage().removeItem(key)
133
+ },
125
134
  storageKey(options.project ?? apiKey.slice(0, 16)),
126
135
  decodePersisted,
127
136
  report
128
137
  );
129
138
 
130
- let consent = options.consent ?? true;
131
- if (options.respectDoNotTrack && doNotTrackEnabled()) consent = false;
139
+ // DNT and GPC ask sites not to follow the visitor, and the homepage
140
+ // promises our script listens: such a page sends recapi nothing about them
141
+ // and keeps nothing on the device, so its lists are popular or similar
142
+ // items only. A banner's consent doesn't lift it: the browser's answer is
143
+ // the visitor's standing one. `respectDoNotTrack: false` is for a site with
144
+ // its own legal basis.
145
+ const quiet = (options.respectDoNotTrack ?? true) && doNotTrackEnabled();
146
+ let consent = !quiet && (options.consent ?? true);
132
147
 
133
148
  /** @type {HistoryState} */
134
149
  let history = emptyHistory();
@@ -400,6 +415,7 @@ export function createClient(options) {
400
415
  * `reject` is -10). A negative level is an explicit rejection ("not
401
416
  * interested"): the item is never recommended to this user again until
402
417
  * a positive event on it. Don't send skips or bounces as rejections.
418
+ * Does nothing under Do Not Track (see `respectDoNotTrack`).
403
419
  * @param {{ recommendationId?: string | null }} [options] Overrides the
404
420
  * automatic attribution. `null` (a fallback list's id) is no override.
405
421
  */
@@ -415,6 +431,7 @@ export function createClient(options) {
415
431
  "level must be an integer between -10 and 10, or 'view', 'cart', 'purchase' or 'reject'"
416
432
  );
417
433
  }
434
+ if (quiet) return;
418
435
  const t = now();
419
436
  let attributed = recommendationId;
420
437
  mutate(() => {
@@ -492,12 +509,14 @@ export function createClient(options) {
492
509
  * `GET /v1/users/{id}/history`, fetched by your backend with the secret
493
510
  * key) and merges it into the local history. Only pass a snapshot that
494
511
  * does not already contain this device's events, or they count twice.
512
+ * Does nothing under Do Not Track: requests name nobody.
495
513
  *
496
514
  * @param {string} id
497
515
  * @param {{ history?: ServerSnapshot }} [options]
498
516
  */
499
517
  identify(id, { history: snapshot } = {}) {
500
518
  if (!isId(id)) throw new RecapiError('invalid_request', 'userId must be 1-256 bytes of UTF-8 without control characters');
519
+ if (quiet) return;
501
520
  mutate(() => {
502
521
  if (userId && userId !== id) {
503
522
  history = emptyHistory();
@@ -531,12 +550,13 @@ export function createClient(options) {
531
550
  * consent nothing is read from or written to storage and no anonymous id
532
551
  * exists; the history lives in memory for the current page only.
533
552
  * Withdrawing deletes the stored data. Granting merges any previously
534
- * stored history with the in-memory one.
553
+ * stored history with the in-memory one. Does nothing under Do Not Track:
554
+ * nothing is stored whatever the banner says.
535
555
  *
536
556
  * @param {boolean} value
537
557
  */
538
558
  setConsent(value) {
539
- if (value === consent) return;
559
+ if (quiet || value === consent) return;
540
560
  consent = Boolean(value);
541
561
  if (!consent) {
542
562
  store.remove();