@recapi/client 0.6.0 → 0.6.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/README.md CHANGED
@@ -1,13 +1,32 @@
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.
4
+ Node ≥ 18. Plain ES modules, no build step, no runtime dependencies
5
+ (from CommonJS or Jest: [see here](#commonjs-typescript-and-jest)).
5
6
 
6
7
  The SDK keeps the user's interaction history on the device and sends it
7
8
  with every recommendation request. Recommendations reflect the latest
8
9
  click at once, and the server does not need to look anything up. The SDK
9
10
  also batches events and attributes them to recommendations for you.
10
11
 
12
+ ## Supported browsers
13
+
14
+ The current and previous two major versions of Chrome, Edge and Firefox,
15
+ and Safari 15.4 or later on macOS and iOS (every iPhone browser runs
16
+ Safari's engine). The code needs at least Chrome and Edge 93, Firefox 92
17
+ and Safari 15.4 (`Object.hasOwn`; `??=` and `||=` need less): a change that
18
+ needs a newer browser moves this floor and says so under [Changes](#changes).
19
+
20
+ On every change to the library, CI runs what a shop page relies on in
21
+ Chromium, Firefox and WebKit, Safari's engine
22
+ ([`scripts/e2e/browsers.test.js`](../../scripts/e2e/browsers.test.js)): the
23
+ lists of `recommend()` and `popular()`, a view and a cart credited to the
24
+ recommendation, the history after a reload, the queued events sent when the
25
+ page is left or hidden, consent and Global Privacy Control. That WebKit is
26
+ Playwright's Linux build, not Safari on an iPhone: the engine is the same,
27
+ but Safari's own storage rules (data deleted after seven days without a
28
+ visit, private browsing) are not part of it.
29
+
11
30
  ## Install
12
31
 
13
32
  On a site without a build step (a theme, plain HTML), load the SDK from
@@ -16,7 +35,7 @@ are, gzipped, and browsers cache them for a year:
16
35
 
17
36
  ```html
18
37
  <script type="module">
19
- import { createClient } from 'https://api.example.com/sdk/0.6.0/index.js';
38
+ import { createClient } from 'https://api.example.com/sdk/0.6.2/index.js';
20
39
 
21
40
  const recapi = createClient({ apiKey: 'rk_pub_…', baseUrl: 'https://api.example.com' });
22
41
  recapi.track('item-42', 'view'); // and the calls of the quick start below
@@ -111,7 +130,11 @@ in two places, both shown with your address in the dashboard's quick start:
111
130
  it; others get Shopify's converted price from the lookup). A row whose
112
131
  items all carry metadata then costs one request instead of seven. The
113
132
  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
133
+ marks a sold-out product inactive there, since no lookup checks it. The
134
+ products export upload and the product webhooks (the quick start's step 4,
135
+ [API](../../docs/api.md#post-v1shopifywebhooks--shopifys-product-webhooks))
136
+ fill it without code; a card whose metadata says `available: false` (maybe
137
+ sold out) is looked up instead. A default style makes them a product row
115
138
  in the theme's colours and fonts; all its selectors sit inside
116
139
  `:where(#recapi-recommendations …)`, which has no specificity, so any of
117
140
  the theme's rules overrides it. It follows the shop's cookie banner through
@@ -150,8 +173,8 @@ hidden for the whole test.
150
173
 
151
174
  Both files pass the same [`project`](#createclientoptions), which the quick start fills
152
175
  in for your environment, so they share the visitor's state whichever public
153
- key each holds: keys are shown once, and a pixel added later with a new key
154
- still credits each add to cart to the visitor and their recommendation.
176
+ key each holds: the quick start fills both with the same key, but a pixel added
177
+ later with another key still credits each add to cart to the visitor and their recommendation.
155
178
  Shopify's `product_added_to_cart` event carries only the cart line, not the
156
179
  cart's attributes
157
180
  ([docs](https://shopify.dev/docs/api/web-pixels-api/standard-events/product_added_to_cart)),
@@ -287,6 +310,23 @@ stands in for WordPress, run by `scripts/php.sh`) and runs the printed
287
310
  script against the SDK. The quick start fills in the current version and
288
311
  your address (`jssdk.WooCommerce`).
289
312
 
313
+ `scripts/e2e_woocommerce.sh` (CI job `woocommerce`) runs the plugin on a
314
+ real, throwaway shop: WordPress and WooCommerce in Docker at pinned
315
+ versions, twice: the newest (WordPress 7.1.4, WooCommerce 11.2.1, PHP 8.5)
316
+ and the oldest the plugin's header declares (WordPress 6.3, WooCommerce 8.3,
317
+ PHP 7.4), the plugin taken from the quick start of a new recapi shop with
318
+ its public key pasted, then a guest's browser (Playwright) on a product
319
+ page, a card of recapi's row, the add to cart and the block checkout
320
+ (`scripts/e2e/woocommerce.test.js`). It fails unless recapi has the
321
+ catalog the secret key sent, the view, the click with its recommendation,
322
+ the cart and exactly one purchase for the order, and a PHP warning or
323
+ deprecation from the plugin in WordPress's debug log fails it. Run it from the
324
+ repository root on any machine with Docker and python3:
325
+
326
+ ```sh
327
+ ./scripts/e2e_woocommerce.sh
328
+ ```
329
+
290
330
  ### Magento
291
331
 
292
332
  The Magento 2 module ([`sdk/php/magento`](../php/magento/README.md)) prints
@@ -317,6 +357,108 @@ await recapi.destroy(); // flushes queued events before exit
317
357
  Timers do not keep a Node process alive. Call `await client.flush()` or
318
358
  `await client.destroy()` before exiting, or queued events are lost.
319
359
 
360
+ ### Serverless and edge
361
+
362
+ A request handler never exits: once the response is out, Cloudflare
363
+ Workers cancel the 2 s timer that would send the queue, Vercel suspends
364
+ the function and AWS Lambda freezes it. Events still queued then are lost,
365
+ with no error. Send the queue after the response with the host's own call:
366
+
367
+ - Cloudflare Workers: `ctx.waitUntil(visitor.flush())`, as below.
368
+ - Next.js 15.1 or later (route handlers, server actions, server
369
+ components): `after(() => visitor.flush())`, with `after` from
370
+ `next/server`. On Vercel without Next.js, `waitUntil(visitor.flush())`
371
+ from `@vercel/functions`.
372
+ - AWS Lambda: `await visitor.flush()` before the handler returns.
373
+
374
+ A client is one visitor, like a browser tab: its history, user id and
375
+ attribution belong to whoever it was used for, and one isolate serves many
376
+ requests at once. Share a client only for lists that are no one's, such as
377
+ popular items, and make one per request for a visitor's events and
378
+ recommendations. `consent: false` keeps such a client from drawing a new
379
+ anonymous id each time; it sends the user id you give `identify()`.
380
+
381
+ ```js
382
+ // Cloudflare Workers
383
+ import { env } from 'cloudflare:workers';
384
+ import { createClient } from '@recapi/client';
385
+
386
+ const options = { apiKey: env.RECAPI_KEY, baseUrl: 'https://api.example.com' };
387
+ const shared = createClient(options); // popular items: they are no one's
388
+
389
+ export default {
390
+ async fetch(request, env, ctx) {
391
+ const visitor = createClient({ ...options, consent: false });
392
+ visitor.identify('cust-881');
393
+ visitor.track('item-42', 'purchase');
394
+ ctx.waitUntil(visitor.flush()); // sends the events after the response
395
+ const { items } = await shared.popular({ count: 4 });
396
+ return Response.json(items);
397
+ }
398
+ };
399
+ ```
400
+
401
+ `make test` runs this block as it stands here in workerd, the Workers
402
+ runtime. A client may be created at module scope, where Workers allow no
403
+ random values or timers: it draws its anonymous id at the first call.
404
+ Outside a page, `popular()` asks with `cache: 'no-store'`, so Next.js
405
+ neither keeps one answer for every visitor (Next.js 14) nor fetches the
406
+ list once at build time for a static page (15 and later). On Workers
407
+ older than compatibility date 2024-11-11, which refuse that option, it
408
+ asks without it.
409
+
410
+ ### CommonJS, TypeScript and Jest
411
+
412
+ The package is ES modules only. A project of ES modules (`"type":
413
+ "module"`, `.mjs`) imports it as above; a CommonJS one loads it by Node
414
+ version. On Node 20.19+ or 22.12+, plain `require` works:
415
+
416
+ ```js
417
+ // CommonJS, Node 20.19+ or 22.12+
418
+ const { createClient } = require('@recapi/client');
419
+
420
+ const recapi = createClient({ apiKey: process.env.RECAPI_KEY, baseUrl: 'https://api.example.com' });
421
+ ```
422
+
423
+ On Node 18, or 20 before 20.19, `require` throws `ERR_REQUIRE_ESM`. Load
424
+ the library once with `import()`, which CommonJS has on every Node, then
425
+ `await recapi` where you use it: `(await recapi).track('item-42', 'view')`.
426
+
427
+ ```js
428
+ // CommonJS, Node 18 and later
429
+ const recapi = import('@recapi/client').then(({ createClient }) =>
430
+ createClient({ apiKey: process.env.RECAPI_KEY, baseUrl: 'https://api.example.com' }));
431
+ ```
432
+
433
+ `make test` runs both blocks as they stand here, on Node 18 and the
434
+ current Node.
435
+
436
+ TypeScript compiled to CommonJS turns `import { createClient } from
437
+ '@recapi/client'` into a `require`, so it needs Node 20.19+ or 22.12+ too.
438
+ On Node 18 write the `import()` above, with `"module": "nodenext"` in
439
+ `tsconfig.json` (NestJS's scaffold has it): under `"module": "commonjs"`,
440
+ TypeScript turns `import()` into a `require` as well.
441
+
442
+ Jest runs tests through its own module loader and leaves `node_modules`
443
+ uncompiled, so a test that reaches the library fails with "Must use import
444
+ to load ES Module" ("Cannot use import statement outside a module" before
445
+ Jest 30). Jest 30 started with `node --experimental-vm-modules` on Node
446
+ 24.9 or later, as NestJS 12's `npm test` does, loads it as is: change
447
+ nothing there. Anywhere else, let Jest compile the library:
448
+
449
+ ```js
450
+ // jest.config.js, or the "jest" key of package.json
451
+ transformIgnorePatterns: ['/node_modules/(?!@recapi/)'],
452
+ ```
453
+
454
+ Jest then compiles it with the transform it uses for `.js` files. With
455
+ ts-jest, that transform's pattern must match `.js` (NestJS's
456
+ `'^.+\\.(t|j)s$': 'ts-jest'` does). With Jest's default babel-jest, add a
457
+ `babel.config.js` with `presets: [['@babel/preset-env', { targets: {
458
+ node: 'current' } }]]`. Don't add the line where nothing was needed:
459
+ there ts-jest's output fails with "exports is not defined". Vitest loads
460
+ the library as is.
461
+
320
462
  ### Showing why an item is listed
321
463
 
322
464
  Every item carries `why`, a short [reason code](../../docs/api.md#reason-codes)
@@ -636,9 +778,16 @@ const storage = {
636
778
  setItem: (k, v) => { cache.set(k, v); AsyncStorage.setItem(k, v); },
637
779
  removeItem: (k) => { cache.delete(k); AsyncStorage.removeItem(k); }
638
780
  };
639
- createClient({ apiKey, baseUrl, project: 'myproject', storage });
781
+ const client = createClient({ apiKey, baseUrl, project: 'myproject', storage });
640
782
  ```
641
783
 
784
+ React Native has no page-hide event, so send the queue when the app leaves
785
+ the screen: call `client.flush()` when `AppState` changes to `background`.
786
+ `popular()` and the fallback list work on React Native below 0.80 as well,
787
+ whose `URLSearchParams` has no working `set`. We read that class in React
788
+ Native's 0.79 and 0.80 sources and test against a copy of its behaviour; the
789
+ library has not been run on a device.
790
+
642
791
  Corrupt or unrecognised stored data is discarded. Bumping the `v1` in the
643
792
  key on a format change makes old data invisible.
644
793
 
@@ -653,6 +802,16 @@ minifier, to keep zero dev dependencies.
653
802
 
654
803
  ## Changes
655
804
 
805
+ - 2026-11-03: version 0.6.2. `popular()` and the fallback list of
806
+ `recommend()` no longer throw on React Native below 0.80, whose
807
+ `URLSearchParams.set` is not implemented. Query strings are unchanged
808
+ everywhere else.
809
+ - 2026-11-02: version 0.6.1. The client draws its anonymous id at the
810
+ first call instead of in `createClient`, so a back end on Cloudflare
811
+ Workers may create it at module scope (it threw "Disallowed operation
812
+ called within global scope"). Outside a page, `popular()` asks with
813
+ `cache: 'no-store'`, so Next.js no longer serves a frozen list. Recipes
814
+ per host: [Serverless and edge](#serverless-and-edge).
656
815
  - 2026-11-01: version 0.6.0. `getAnonId()` returns the device's anonymous
657
816
  id, for the backend to fetch the login snapshot without this device's own
658
817
  browsing (`?anon_id=`), which would otherwise count twice on a device not
@@ -698,8 +857,18 @@ minifier, to keep zero dev dependencies.
698
857
 
699
858
  ```sh
700
859
  npm test # node --test, zero dev dependencies
860
+ ../../scripts/node18.sh --test test/*.test.js # the same on Node 18, the floor
701
861
  ```
702
862
 
863
+ `make test` and CI run the tests on Node 18 as well as the current Node,
864
+ so a built-in newer than the `engines` floor (`Object.groupBy`,
865
+ `Promise.withResolvers`, `toSorted`…) fails there. Raising the floor
866
+ means changing `engines`, this README's first lines and
867
+ `docs/development.md` together.
868
+
869
+ The same steps in real browsers (Chromium, Firefox, WebKit): see
870
+ [Testing](../../docs/development.md#testing).
871
+
703
872
  The API embeds `src/*.js` (module `recapi/sdkjs`, `embed.go`) and serves
704
873
  them at `/sdk/<version>/`, where browsers cache them for a year. So a
705
874
  change to `src/` needs a new version: bump `version` in `package.json`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recapi/client",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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
@@ -91,7 +91,9 @@ const PUBLIC_KEY_PREFIX = 'rk_pub_';
91
91
  */
92
92
 
93
93
  /**
94
- * Creates a client. Nothing is sent until `track()`/`recommend()` is called.
94
+ * Creates a client. Nothing is sent until `track()`/`recommend()` is called,
95
+ * and nothing is stored or drawn at random before the first call, so a back
96
+ * end may create it at module scope (Cloudflare Workers forbid both there).
95
97
  *
96
98
  * @param {ClientOptions} options
97
99
  */
@@ -121,6 +123,7 @@ export function createClient(options) {
121
123
  }
122
124
  };
123
125
  const transport = createTransport({ baseUrl, apiKey, fetch: fetchImpl, timeoutMs });
126
+ const popularCache = serverCacheMode();
124
127
  // The default adapter probes localStorage with a write, so it is picked at
125
128
  // the first storage access: never before consent, nor under Do Not Track.
126
129
  let adapter = options.storage;
@@ -166,8 +169,19 @@ export function createClient(options) {
166
169
  let destroyed = false;
167
170
  /** False after a failed write: storage is then stale and memory is the truth. */
168
171
  let synced = true;
172
+ let started = false;
169
173
 
170
- if (consent) {
174
+ /**
175
+ * Loads the stored state, or makes and stores the anonymous id, on the
176
+ * first call rather than in `createClient`: Cloudflare Workers forbid
177
+ * random values at module scope, where a back end creates its client. A
178
+ * page calls the client before it sends anything, so the id is the same
179
+ * and stored before the first event either way.
180
+ */
181
+ function start() {
182
+ if (started) return;
183
+ started = true;
184
+ if (!consent) return;
171
185
  reload();
172
186
  if (!anonId) {
173
187
  anonId = uuid();
@@ -388,6 +402,7 @@ export function createClient(options) {
388
402
  method: 'GET',
389
403
  path: '/v1/popular',
390
404
  auth: apiKey.startsWith(PUBLIC_KEY_PREFIX) ? 'query' : 'header',
405
+ cache: popularCache,
391
406
  timeoutMs: deadline,
392
407
  query: { tags_any: tagsAny?.join(','), tags_all: tagsAll?.join(','), count }
393
408
  });
@@ -420,6 +435,7 @@ export function createClient(options) {
420
435
  * automatic attribution. `null` (a fallback list's id) is no override.
421
436
  */
422
437
  track(itemId, level, { recommendationId: explicit } = {}) {
438
+ start();
423
439
  // A fallback list has a null id, and a page passing it on must not
424
440
  // send `null` or lose the item's earlier, real attribution.
425
441
  const recommendationId = explicit ?? undefined;
@@ -468,6 +484,7 @@ export function createClient(options) {
468
484
  * items: Array<{ itemId: string, score: number, slot: string, why?: string, metadata?: object }> }>}
469
485
  */
470
486
  async recommend(params = {}) {
487
+ start();
471
488
  const deadline = Math.min(timeoutMs, RECOMMEND_TIMEOUT_MS);
472
489
  try {
473
490
  return await personalised(params, deadline);
@@ -516,6 +533,7 @@ export function createClient(options) {
516
533
  * @param {{ history?: ServerSnapshot }} [options]
517
534
  */
518
535
  identify(id, { history: snapshot } = {}) {
536
+ start();
519
537
  if (!isId(id)) throw new RecapiError('invalid_request', 'userId must be 1-256 bytes of UTF-8 without control characters');
520
538
  if (quiet) return;
521
539
  mutate(() => {
@@ -538,6 +556,7 @@ export function createClient(options) {
538
556
  * ids they were tracked with.
539
557
  */
540
558
  reset() {
559
+ start();
541
560
  history = emptyHistory();
542
561
  userId = null;
543
562
  shown = new Map();
@@ -557,6 +576,7 @@ export function createClient(options) {
557
576
  * @param {boolean} value
558
577
  */
559
578
  setConsent(value) {
579
+ start();
560
580
  if (quiet || value === consent) return;
561
581
  consent = Boolean(value);
562
582
  if (!consent) {
@@ -585,6 +605,7 @@ export function createClient(options) {
585
605
  * @returns {{ history: Array<[string, number, number]>, recent: string[], rejected: string[] }}
586
606
  */
587
607
  getHistory() {
608
+ start();
588
609
  if (consent) reload();
589
610
  return toWire(history, now(), config);
590
611
  },
@@ -599,6 +620,7 @@ export function createClient(options) {
599
620
  * @returns {string | null}
600
621
  */
601
622
  getAnonId() {
623
+ start();
602
624
  if (consent) reload();
603
625
  return anonId;
604
626
  },
@@ -699,6 +721,26 @@ function compact(object) {
699
721
  return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
700
722
  }
701
723
 
724
+ /**
725
+ * fetch's cache mode for `GET /v1/popular`: `no-store` outside a page.
726
+ * Next.js keeps a server fetch's answer for every visitor (14), or fetches
727
+ * once at build time for a static page (15 and later), and the list then
728
+ * never changes. A page keeps the browser's own cache, which follows the
729
+ * API's 60 s max-age. None where fetch refuses the field: Workers before
730
+ * compatibility date 2024-11-11 throw on any `cache`, even `no-store`.
731
+ *
732
+ * @returns {RequestCache | undefined}
733
+ */
734
+ function serverCacheMode() {
735
+ if (globalThis.document !== undefined) return undefined;
736
+ try {
737
+ new Request('http://localhost/', { cache: 'no-store' });
738
+ return 'no-store';
739
+ } catch {
740
+ return undefined;
741
+ }
742
+ }
743
+
702
744
  function doNotTrackEnabled() {
703
745
  const nav = globalThis.navigator;
704
746
  return nav?.doNotTrack === '1' || nav?.globalPrivacyControl === true;
package/src/transport.js CHANGED
@@ -65,6 +65,7 @@ export class RecapiError extends Error {
65
65
  * @property {'header' | 'query'} [auth] `query` sends `?key=` so GET
66
66
  * responses are CDN-cacheable and need no CORS preflight.
67
67
  * @property {boolean} [keepalive] Let the request outlive the page.
68
+ * @property {RequestCache} [cache] fetch's cache mode; the runtime's default when unset.
68
69
  * @property {number} [timeoutMs]
69
70
  */
70
71
 
@@ -76,18 +77,20 @@ export function createTransport({ baseUrl, apiKey, fetch, timeoutMs: defaultTime
76
77
  const root = baseUrl.replace(/\/+$/, '');
77
78
 
78
79
  /** @param {RequestOptions} options */
79
- async function request({ method, path, query, body, auth = 'header', keepalive = false, timeoutMs }) {
80
+ async function request({ method, path, query, body, auth = 'header', keepalive = false, cache, timeoutMs }) {
81
+ // append, not set: React Native below 0.80 throws on set, and these names are unique.
80
82
  const params = new URLSearchParams();
81
83
  for (const [name, value] of Object.entries(query ?? {})) {
82
- if (value !== undefined) params.set(name, String(value));
84
+ if (value !== undefined) params.append(name, String(value));
83
85
  }
84
- if (auth === 'query') params.set('key', apiKey);
86
+ if (auth === 'query') params.append('key', apiKey);
85
87
  const qs = params.toString();
86
88
  const url = `${root}${path}${qs ? `?${qs}` : ''}`;
87
89
 
88
90
  const headers = { Accept: 'application/json' };
89
91
  if (auth === 'header') headers.Authorization = `Bearer ${apiKey}`;
90
92
  const init = { method, headers };
93
+ if (cache) init.cache = cache;
91
94
  if (body !== undefined) {
92
95
  headers['Content-Type'] = 'application/json';
93
96
  init.body = JSON.stringify(body);