@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 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.5.1/index.js';
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. 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)
@@ -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: on such a device, what was
546
- browsed there since would count twice.
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' | (string & {});
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recapi/client",
3
- "version": "0.5.1",
3
+ "version": "0.6.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
@@ -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);
@@ -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);