@recapi/client 0.5.1 → 0.6.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 +183 -8
- package/index.d.ts +4 -1
- package/package.json +1 -1
- package/src/client.js +60 -3
- package/src/transport.js +3 -1
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.
|
|
38
|
+
import { createClient } from 'https://api.example.com/sdk/0.6.1/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.
|
|
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:
|
|
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)
|
|
@@ -327,7 +469,7 @@ come):
|
|
|
327
469
|
```js
|
|
328
470
|
const LABELS = {
|
|
329
471
|
for_you: 'Picked for you', similar: 'Similar items', next: 'Up next',
|
|
330
|
-
trending: 'Trending now', popular: 'Popular', new: 'New',
|
|
472
|
+
bought_together: 'Often bought together', trending: 'Trending now', popular: 'Popular', new: 'New',
|
|
331
473
|
};
|
|
332
474
|
for (const item of items) {
|
|
333
475
|
const label = LABELS[item.why]; // undefined for explore, more and future codes: no label
|
|
@@ -398,6 +540,9 @@ for (const item of items) {
|
|
|
398
540
|
- **`getHistory()`** returns `{ history, recent, rejected }` exactly as the
|
|
399
541
|
next request would send it (`rejected` goes into `exclude`). Use it for
|
|
400
542
|
debugging.
|
|
543
|
+
- **`getAnonId()`** returns this device's anonymous id, the `anon_id` its
|
|
544
|
+
events carry, or `null` without consent and under Do Not Track. See
|
|
545
|
+
[Login and logout](#login-and-logout).
|
|
401
546
|
- **`destroy()`** removes listeners and timers, then flushes.
|
|
402
547
|
|
|
403
548
|
### Errors
|
|
@@ -542,8 +687,18 @@ so a batch already in flight when the user navigates away still arrives.
|
|
|
542
687
|
item by item. Use it only when the snapshot does not already contain
|
|
543
688
|
this device's events, typically right after login. Otherwise those
|
|
544
689
|
events count twice. The snapshot also holds the anonymous browsing of
|
|
545
|
-
the devices the user logged in on before
|
|
546
|
-
|
|
690
|
+
the devices the user logged in on before, this one included when it was
|
|
691
|
+
not `reset()` at logout. Send `getAnonId()` with the login request and
|
|
692
|
+
have your backend ask for `GET /v1/users/{id}/history?anon_id=…`: the
|
|
693
|
+
snapshot then leaves out this device's own browsing.
|
|
694
|
+
|
|
695
|
+
```js
|
|
696
|
+
// Browser, at login:
|
|
697
|
+
const res = await fetch('/login', { method: 'POST', body: JSON.stringify({ ...form, anonId: recapi.getAnonId() }) });
|
|
698
|
+
recapi.identify(userId, { history: (await res.json()).recapiHistory });
|
|
699
|
+
// Backend, with the secret key:
|
|
700
|
+
// GET /v1/users/{userId}/history?anon_id={anonId} (leave anon_id out when it is null)
|
|
701
|
+
```
|
|
547
702
|
- Merge rule for the recent lists: take the union of both lists and order
|
|
548
703
|
it by interaction time, oldest first. An item in both lists takes its
|
|
549
704
|
latest time. Then keep the last 20.
|
|
@@ -640,6 +795,16 @@ minifier, to keep zero dev dependencies.
|
|
|
640
795
|
|
|
641
796
|
## Changes
|
|
642
797
|
|
|
798
|
+
- 2026-11-02: version 0.6.1. The client draws its anonymous id at the
|
|
799
|
+
first call instead of in `createClient`, so a back end on Cloudflare
|
|
800
|
+
Workers may create it at module scope (it threw "Disallowed operation
|
|
801
|
+
called within global scope"). Outside a page, `popular()` asks with
|
|
802
|
+
`cache: 'no-store'`, so Next.js no longer serves a frozen list. Recipes
|
|
803
|
+
per host: [Serverless and edge](#serverless-and-edge).
|
|
804
|
+
- 2026-11-01: version 0.6.0. `getAnonId()` returns the device's anonymous
|
|
805
|
+
id, for the backend to fetch the login snapshot without this device's own
|
|
806
|
+
browsing (`?anon_id=`), which would otherwise count twice on a device not
|
|
807
|
+
reset at logout.
|
|
643
808
|
- 2026-11-01: version 0.5.1. Without consent, and under Do Not Track, the
|
|
644
809
|
SDK no longer touches `localStorage` at all: before, it wrote and removed a
|
|
645
810
|
probe key when the client was created, to check that storage works. Now
|
|
@@ -681,8 +846,18 @@ minifier, to keep zero dev dependencies.
|
|
|
681
846
|
|
|
682
847
|
```sh
|
|
683
848
|
npm test # node --test, zero dev dependencies
|
|
849
|
+
../../scripts/node18.sh --test test/*.test.js # the same on Node 18, the floor
|
|
684
850
|
```
|
|
685
851
|
|
|
852
|
+
`make test` and CI run the tests on Node 18 as well as the current Node,
|
|
853
|
+
so a built-in newer than the `engines` floor (`Object.groupBy`,
|
|
854
|
+
`Promise.withResolvers`, `toSorted`…) fails there. Raising the floor
|
|
855
|
+
means changing `engines`, this README's first lines and
|
|
856
|
+
`docs/development.md` together.
|
|
857
|
+
|
|
858
|
+
The same steps in real browsers (Chromium, Firefox, WebKit): see
|
|
859
|
+
[Testing](../../docs/development.md#testing).
|
|
860
|
+
|
|
686
861
|
The API embeds `src/*.js` (module `recapi/sdkjs`, `embed.go`) and serves
|
|
687
862
|
them at `/sdk/<version>/`, where browsers cache them for a year. So a
|
|
688
863
|
change to `src/` needs a new version: bump `version` in `package.json`,
|
package/index.d.ts
CHANGED
|
@@ -104,7 +104,8 @@ export interface RecommendParams {
|
|
|
104
104
|
|
|
105
105
|
/** An item's reason code: map the ones you want to your own label, show none for an unknown one. */
|
|
106
106
|
export type ReasonCode =
|
|
107
|
-
| 'for_you' | 'similar' | 'next' | 'trending' | 'popular' | 'new' | 'explore' | 'more'
|
|
107
|
+
| 'for_you' | 'similar' | 'next' | 'bought_together' | 'trending' | 'popular' | 'new' | 'explore' | 'more'
|
|
108
|
+
| (string & {});
|
|
108
109
|
|
|
109
110
|
/** The object stored with the item (`PUT /v1/items`, `metadata`): name, picture, link, price... */
|
|
110
111
|
export type ItemMetadata = Record<string, unknown>;
|
|
@@ -159,6 +160,8 @@ export interface RecapiClient {
|
|
|
159
160
|
/** Never rejects; failures go to `onError`. */
|
|
160
161
|
flush(): Promise<void>;
|
|
161
162
|
getHistory(): WireHistory;
|
|
163
|
+
/** This device's anonymous id; null without consent or under Do Not Track. */
|
|
164
|
+
getAnonId(): string | null;
|
|
162
165
|
destroy(): Promise<void>;
|
|
163
166
|
}
|
|
164
167
|
|
package/package.json
CHANGED
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
|
-
|
|
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);
|
|
@@ -508,13 +525,15 @@ export function createClient(options) {
|
|
|
508
525
|
* `options.history` takes a server snapshot (from
|
|
509
526
|
* `GET /v1/users/{id}/history`, fetched by your backend with the secret
|
|
510
527
|
* key) and merges it into the local history. Only pass a snapshot that
|
|
511
|
-
* does not already contain this device's events, or they count twice
|
|
528
|
+
* does not already contain this device's events, or they count twice:
|
|
529
|
+
* fetch it with this device's `getAnonId()`.
|
|
512
530
|
* Does nothing under Do Not Track: requests name nobody.
|
|
513
531
|
*
|
|
514
532
|
* @param {string} id
|
|
515
533
|
* @param {{ history?: ServerSnapshot }} [options]
|
|
516
534
|
*/
|
|
517
535
|
identify(id, { history: snapshot } = {}) {
|
|
536
|
+
start();
|
|
518
537
|
if (!isId(id)) throw new RecapiError('invalid_request', 'userId must be 1-256 bytes of UTF-8 without control characters');
|
|
519
538
|
if (quiet) return;
|
|
520
539
|
mutate(() => {
|
|
@@ -537,6 +556,7 @@ export function createClient(options) {
|
|
|
537
556
|
* ids they were tracked with.
|
|
538
557
|
*/
|
|
539
558
|
reset() {
|
|
559
|
+
start();
|
|
540
560
|
history = emptyHistory();
|
|
541
561
|
userId = null;
|
|
542
562
|
shown = new Map();
|
|
@@ -556,6 +576,7 @@ export function createClient(options) {
|
|
|
556
576
|
* @param {boolean} value
|
|
557
577
|
*/
|
|
558
578
|
setConsent(value) {
|
|
579
|
+
start();
|
|
559
580
|
if (quiet || value === consent) return;
|
|
560
581
|
consent = Boolean(value);
|
|
561
582
|
if (!consent) {
|
|
@@ -584,10 +605,26 @@ export function createClient(options) {
|
|
|
584
605
|
* @returns {{ history: Array<[string, number, number]>, recent: string[], rejected: string[] }}
|
|
585
606
|
*/
|
|
586
607
|
getHistory() {
|
|
608
|
+
start();
|
|
587
609
|
if (consent) reload();
|
|
588
610
|
return toWire(history, now(), config);
|
|
589
611
|
},
|
|
590
612
|
|
|
613
|
+
/**
|
|
614
|
+
* This device's anonymous id, the `anon_id` its events carry: null
|
|
615
|
+
* without consent or under Do Not Track. Send it with the login request,
|
|
616
|
+
* so your backend asks `GET /v1/users/{id}/history?anon_id=…` for the
|
|
617
|
+
* snapshot it passes to `identify()`: that snapshot then leaves out this
|
|
618
|
+
* device's browsing, which the local history holds already.
|
|
619
|
+
*
|
|
620
|
+
* @returns {string | null}
|
|
621
|
+
*/
|
|
622
|
+
getAnonId() {
|
|
623
|
+
start();
|
|
624
|
+
if (consent) reload();
|
|
625
|
+
return anonId;
|
|
626
|
+
},
|
|
627
|
+
|
|
591
628
|
/**
|
|
592
629
|
* Removes listeners and timers, then flushes what is queued.
|
|
593
630
|
*
|
|
@@ -684,6 +721,26 @@ function compact(object) {
|
|
|
684
721
|
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
|
|
685
722
|
}
|
|
686
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
|
+
|
|
687
744
|
function doNotTrackEnabled() {
|
|
688
745
|
const nav = globalThis.navigator;
|
|
689
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,7 +77,7 @@ 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 }) {
|
|
80
81
|
const params = new URLSearchParams();
|
|
81
82
|
for (const [name, value] of Object.entries(query ?? {})) {
|
|
82
83
|
if (value !== undefined) params.set(name, String(value));
|
|
@@ -88,6 +89,7 @@ export function createTransport({ baseUrl, apiKey, fetch, timeoutMs: defaultTime
|
|
|
88
89
|
const headers = { Accept: 'application/json' };
|
|
89
90
|
if (auth === 'header') headers.Authorization = `Bearer ${apiKey}`;
|
|
90
91
|
const init = { method, headers };
|
|
92
|
+
if (cache) init.cache = cache;
|
|
91
93
|
if (body !== undefined) {
|
|
92
94
|
headers['Content-Type'] = 'application/json';
|
|
93
95
|
init.body = JSON.stringify(body);
|