@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 +175 -6
- package/package.json +1 -1
- package/src/client.js +44 -2
- package/src/transport.js +6 -3
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.
|
|
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.
|
|
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)
|
|
@@ -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
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);
|
|
@@ -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.
|
|
84
|
+
if (value !== undefined) params.append(name, String(value));
|
|
83
85
|
}
|
|
84
|
-
if (auth === 'query') params.
|
|
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);
|