@recapi/client 0.6.1 → 0.6.3

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
@@ -35,7 +35,7 @@ are, gzipped, and browsers cache them for a year:
35
35
 
36
36
  ```html
37
37
  <script type="module">
38
- import { createClient } from 'https://api.example.com/sdk/0.6.1/index.js';
38
+ import { createClient } from 'https://api.example.com/sdk/0.6.3/index.js';
39
39
 
40
40
  const recapi = createClient({ apiKey: 'rk_pub_…', baseUrl: 'https://api.example.com' });
41
41
  recapi.track('item-42', 'view'); // and the calls of the quick start below
@@ -327,6 +327,18 @@ repository root on any machine with Docker and python3:
327
327
  ./scripts/e2e_woocommerce.sh
328
328
  ```
329
329
 
330
+ The file's `Version:` header is that version, so a plugin fix an advisory has
331
+ to name needs a new SDK version (a change to the file alone doesn't move it).
332
+ Its `Update URI:` header, on your API's host, tells WordPress (5.8 or later;
333
+ the plugin needs 6.3) that its updates don't come from the WordPress.org
334
+ directory, so a same-named plugin listed there isn't offered as an update. The
335
+ last part is WordPress.org's documented behaviour, on its side of the update
336
+ check: the end-to-end test shows WordPress reading the header, sending it with
337
+ the check, asking the host's `update_plugins_{hostname}` filter and offering
338
+ nothing, and can't show what WordPress.org would do with a same-named listing.
339
+ Nothing answers that filter yet, so a shop gets no update notice. A snippet
340
+ install has neither header's effect: both are comments there.
341
+
330
342
  ### Magento
331
343
 
332
344
  The Magento 2 module ([`sdk/php/magento`](../php/magento/README.md)) prints
@@ -493,7 +505,7 @@ for (const item of items) {
493
505
  | `historyConfig` | spec defaults | `{ halfLifeSeconds, topK, recentSize }`; must match the server |
494
506
  | `consent` | `true` | storage consent, see [Consent](#consent-and-eprivacy) |
495
507
  | `respectDoNotTrack` | `true` | when DNT or Global Privacy Control is on: store nothing, send no events, see [Do Not Track](#do-not-track) |
496
- | `onError` | – | gets errors from background work (flushes, storage, rejected events) and those `recommend()` fell back from |
508
+ | `onError` | `console.warn` for lost events only | gets errors from background work (flushes, storage, rejected events) and those `recommend()` fell back from |
497
509
  | `now`, `fetch`, `sleep` | `Date.now`, global `fetch`, `setTimeout` | for tests |
498
510
 
499
511
  ### Client methods
@@ -536,7 +548,8 @@ for (const item of items) {
536
548
  were tracked with.
537
549
  - **`setConsent(boolean)`**: grants or withdraws storage consent.
538
550
  - **`flush()`**: sends queued events now. Never rejects. Errors go to
539
- `onError`, and batches that can still succeed stay queued.
551
+ `onError` ([without one](#event-delivery): lost events to `console.warn`), and
552
+ batches that can still succeed stay queued.
540
553
  - **`getHistory()`** returns `{ history, recent, rejected }` exactly as the
541
554
  next request would send it (`rejected` goes into `exclude`). Use it for
542
555
  debugging.
@@ -654,6 +667,13 @@ event quota or spend cap is reached, events are lost until it resets or
654
667
  the plan changes. A 202 with some `rejected` events keeps the accepted
655
668
  ones, and the rejected ones are reported as `event_rejected`.
656
669
 
670
+ Without an `onError`, events lost for good (a batch refused as above,
671
+ `event_rejected`, `queue_overflow`) print one `console.warn` line per error
672
+ code per client, so a wrong key shows in the console without flooding a
673
+ visitor's. Failures that can still succeed, storage errors and the errors
674
+ `recommend()` falls back from print nothing. With an `onError`, nothing is
675
+ printed.
676
+
657
677
  When the page is hidden (`visibilitychange` → `hidden`, or `pagehide`),
658
678
  the SDK sends the queue at once using `fetch` with `keepalive: true`.
659
679
  It does not use `navigator.sendBeacon`, because a beacon cannot send the
@@ -778,23 +798,37 @@ const storage = {
778
798
  setItem: (k, v) => { cache.set(k, v); AsyncStorage.setItem(k, v); },
779
799
  removeItem: (k) => { cache.delete(k); AsyncStorage.removeItem(k); }
780
800
  };
781
- createClient({ apiKey, baseUrl, project: 'myproject', storage });
801
+ const client = createClient({ apiKey, baseUrl, project: 'myproject', storage });
782
802
  ```
783
803
 
804
+ React Native has no page-hide event, so send the queue when the app leaves
805
+ the screen: call `client.flush()` when `AppState` changes to `background`.
806
+ `popular()` and the fallback list work on React Native below 0.80 as well,
807
+ whose `URLSearchParams` has no working `set`. We read that class in React
808
+ Native's 0.79 and 0.80 sources and test against a copy of its behaviour; the
809
+ library has not been run on a device.
810
+
784
811
  Corrupt or unrecognised stored data is discarded. Bumping the `v1` in the
785
812
  key on a format change makes old data invisible.
786
813
 
787
814
  ## Size budget
788
815
 
789
816
  Budget: **6 KB gzip** after minification. The sources ship unminified with
790
- full JSDoc: 53 KB raw in five files, 18 KB as the API serves them (gzip -9),
817
+ full JSDoc: 58 KB raw in five files, 20 KB as the API serves them (gzip -9),
791
818
  and the browser caches them for a year. With comments stripped and no
792
- minification they are 7.2 KB gzip. A bundler's minifier should bring
819
+ minification they are 8.5 KB gzip. A bundler's minifier should bring
793
820
  that under the budget, but this has not been measured: the repo has no
794
821
  minifier, to keep zero dev dependencies.
795
822
 
796
823
  ## Changes
797
824
 
825
+ - 2026-11-04: version 0.6.3. Without an `onError`, events lost for good (a
826
+ batch refused for good, `event_rejected`, `queue_overflow`) print one
827
+ `console.warn` line per error code per client ([Event delivery](#event-delivery)).
828
+ - 2026-11-03: version 0.6.2. `popular()` and the fallback list of
829
+ `recommend()` no longer throw on React Native below 0.80, whose
830
+ `URLSearchParams.set` is not implemented. Query strings are unchanged
831
+ everywhere else.
798
832
  - 2026-11-02: version 0.6.1. The client draws its anonymous id at the
799
833
  first call instead of in `createClient`, so a back end on Cloudflare
800
834
  Workers may create it at module scope (it threw "Disallowed operation
package/index.d.ts CHANGED
@@ -83,7 +83,11 @@ export interface ClientOptions {
83
83
  * nothing, send no events and no visitor ids; recommendations still work.
84
84
  */
85
85
  respectDoNotTrack?: boolean;
86
- /** Receives errors from background work (flushes, storage) and those `recommend()` fell back from. */
86
+ /**
87
+ * Receives errors from background work (flushes, storage) and those
88
+ * `recommend()` fell back from. Without it, events lost for good print one
89
+ * `console.warn` line per error code.
90
+ */
87
91
  onError?: (error: unknown) => void;
88
92
  /** Clock in unix ms. */
89
93
  now?: () => number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recapi/client",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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
@@ -84,7 +84,8 @@ const PUBLIC_KEY_PREFIX = 'rk_pub_';
84
84
  * Track or Global Privacy Control (default `true`): store nothing, send no
85
85
  * events and no visitor ids; recommendations are still asked for.
86
86
  * @property {(error: unknown) => void} [onError] Receives background errors,
87
- * and the errors `recommend()` fell back from.
87
+ * and the errors `recommend()` fell back from. Without it, events lost for
88
+ * good print one `console.warn` line per error code.
88
89
  * @property {() => number} [now] Clock in unix ms.
89
90
  * @property {typeof fetch} [fetch]
90
91
  * @property {(ms: number) => Promise<void>} [sleep] Backoff sleep (tests).
@@ -115,9 +116,16 @@ export function createClient(options) {
115
116
  const maxBatch = Math.min(Math.max(1, options.maxBatch ?? 100), MAX_EVENTS_PER_REQUEST);
116
117
  const maxQueue = Math.max(MAX_EVENTS_PER_REQUEST, maxBatch);
117
118
  const config = resolveConfig(options.historyConfig);
118
- const report = (error) => {
119
+ const warned = new Set();
120
+ // `lost`: events are gone for good. Without an onError nothing else would
121
+ // say so; once per code, or a visitor's console fills up.
122
+ const report = (error, lost) => {
119
123
  try {
120
- options.onError?.(error);
124
+ if (options.onError) options.onError(error);
125
+ else if (lost && !warned.has(error.code)) {
126
+ warned.add(error.code);
127
+ globalThis.console?.warn(`recapi: events lost: ${error.code}: ${error.message}`);
128
+ }
121
129
  } catch {
122
130
  // A faulty error handler must not break tracking.
123
131
  }
@@ -242,7 +250,7 @@ export function createClient(options) {
242
250
 
243
251
  function dropOverflow() {
244
252
  const dropped = queue.splice(0, queue.length - maxQueue).length;
245
- report(new RecapiError('queue_overflow', `dropped ${dropped} oldest events`));
253
+ report(new RecapiError('queue_overflow', `dropped ${dropped} oldest events`), true);
246
254
  }
247
255
 
248
256
  function schedule() {
@@ -266,13 +274,13 @@ export function createClient(options) {
266
274
  report(new RecapiError('event_rejected', `${response.rejected.length} event(s) rejected`, {
267
275
  status: 202,
268
276
  details: response.rejected.map((r) => ({ ...r, event: events[r.index] }))
269
- }));
277
+ }), true);
270
278
  }
271
279
  }
272
280
 
273
281
  /** Puts a failed batch back at the head of the queue if it may succeed later. */
274
282
  function handleSendError(error, events) {
275
- report(error);
283
+ report(error, !error.retryable);
276
284
  if (error instanceof RecapiError && error.retryable) {
277
285
  queue.unshift(...events);
278
286
  if (queue.length > maxQueue) dropOverflow();
@@ -314,8 +322,9 @@ export function createClient(options) {
314
322
  }
315
323
 
316
324
  /**
317
- * Sends all queued events. Never rejects: failures go to `onError`, and
318
- * retryable batches stay queued for the next automatic flush.
325
+ * Sends all queued events. Never rejects: failures go to `onError` (without
326
+ * one, events lost for good to `console.warn`), and retryable batches stay
327
+ * queued for the next automatic flush.
319
328
  *
320
329
  * @returns {Promise<void>}
321
330
  */
package/src/transport.js CHANGED
@@ -78,11 +78,12 @@ export function createTransport({ baseUrl, apiKey, fetch, timeoutMs: defaultTime
78
78
 
79
79
  /** @param {RequestOptions} options */
80
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.
81
82
  const params = new URLSearchParams();
82
83
  for (const [name, value] of Object.entries(query ?? {})) {
83
- if (value !== undefined) params.set(name, String(value));
84
+ if (value !== undefined) params.append(name, String(value));
84
85
  }
85
- if (auth === 'query') params.set('key', apiKey);
86
+ if (auth === 'query') params.append('key', apiKey);
86
87
  const qs = params.toString();
87
88
  const url = `${root}${path}${qs ? `?${qs}` : ''}`;
88
89