@findlane/browser 0.11.0 → 0.12.0

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
@@ -60,6 +60,10 @@ import "@findlane/browser/react/styles.css" // optional
60
60
 
61
61
  A response has `hits` (each with its `record` and any `highlights`), `total`, `facets`, and `tookMs`. Pass `{ signal }` as a second argument to cancel a request, as search-as-you-type should.
62
62
 
63
+ ## Events
64
+
65
+ `client.events` sends views, clicks, add-to-carts and orders in the background, for the index's Events page and Bought together. With `visitorIds: true`, they also carry random IDs for the browser and the visit, when the index takes them, and `client.events.forget()` deletes the IDs and asks for their events to be deleted. Docs: https://findlane.dev/docs/events
66
+
63
67
  ## Security
64
68
 
65
69
  The public ID grants search access only to its index, and it is visible in browser source. By default the API returns every uploaded record field. `attributesToRetrieve` requests fewer fields for speed, but it does not make the others private. Origin checks constrain browsers; they do not authenticate other callers. Keep public catalog fields safe to expose. The dashboard can remove origins, add server-enforced restrictions, or rotate the ID.
package/dist/index.d.ts CHANGED
@@ -147,7 +147,7 @@ export type ClickDetails = {
147
147
  from?: string;
148
148
  };
149
149
  /** Events from the page, sent in the background: a batch a second, and what's left when the page is
150
- * hidden or closed. They carry no cookie or ID. */
150
+ * hidden or closed. They carry no cookie, and no ID unless `visitorIds` is on. */
151
151
  export type BrowserEvents = {
152
152
  /** A product's page was viewed. A repeat of the last view within 5 seconds is the same page view
153
153
  * (React's double mount in development, a re-render), so it isn't sent. */
@@ -163,8 +163,12 @@ export type BrowserEvents = {
163
163
  items: EventItem[];
164
164
  currency?: string;
165
165
  }): void;
166
- /** With `events: "afterConsent"`: true sends the events held so far and those after; false drops them. */
166
+ /** With `events: "afterConsent"`: true sends the events held so far and those after; false drops them,
167
+ * and with `visitorIds` deletes the IDs from this device. */
167
168
  consent(granted: boolean): void;
169
+ /** With `visitorIds`: deletes this browser's visitor and visit IDs from the device, and asks Findlane
170
+ * to delete the events sent with them, which it does within a week. Later events get new IDs. */
171
+ forget(): void;
168
172
  /** Sends the events waiting now. */
169
173
  flush(): void;
170
174
  };
@@ -183,6 +187,11 @@ export type BrowserClientOptions = {
183
187
  * `events.proxy`), such as "/findlane/events". Ad blockers block beacons to other sites, not to the
184
188
  * page's own. */
185
189
  eventsUrl?: string;
190
+ /** Whether events carry random IDs for this browser and this visit, which link one shopper's views,
191
+ * carts and orders. The index must take them too (its Events page). Turn it on only if your consent
192
+ * banner covers analytics, or your shoppers don't need one; with `events: "afterConsent"` the IDs
193
+ * wait for consent as events do. */
194
+ visitorIds?: boolean;
186
195
  };
187
196
  export declare class SearchBrowserClient {
188
197
  private readonly url;
package/dist/index.js CHANGED
@@ -8,22 +8,66 @@ export class FindlaneError extends Error {
8
8
  }
9
9
  /** The hosted Findlane API, used unless `baseUrl` says otherwise. */
10
10
  export const defaultBaseUrl = "https://api.findlane.dev";
11
- function eventSender(url, fetcher, mode) {
11
+ const day = 86_400_000;
12
+ /** The visitor and visit IDs of a public ID, kept on the device: the visitor's in localStorage, made
13
+ * anew 13 months after it was made (use doesn't extend it); the visit's in sessionStorage, made anew
14
+ * after 30 minutes without events. Storage that can't be used (a sandboxed frame's) means no IDs. */
15
+ function deviceIds(publicId) {
16
+ const keys = { visitor: `findlane:visitor:${publicId}`, visit: `findlane:visit:${publicId}` };
17
+ // 16 random bytes in base64url: letters, digits, - and _, as the API takes IDs.
18
+ const random = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))))
19
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
20
+ const read = (storage, key) => {
21
+ const value = JSON.parse(storage.getItem(key) ?? "null");
22
+ return typeof value?.id == "string" && typeof value.at == "number" ? value : null;
23
+ };
24
+ return {
25
+ /** The IDs for a request now, made when there are none or they're old. */
26
+ use(now = Date.now()) {
27
+ try {
28
+ let visitor = read(localStorage, keys.visitor);
29
+ if (!visitor || now - visitor.at > 395 * day)
30
+ localStorage.setItem(keys.visitor, JSON.stringify(visitor = { id: random(), at: now }));
31
+ const visit = read(sessionStorage, keys.visit);
32
+ const visitId = visit && now - visit.at <= 30 * 60_000 ? visit.id : random();
33
+ sessionStorage.setItem(keys.visit, JSON.stringify({ id: visitId, at: now }));
34
+ return { visitorId: visitor.id, visitId };
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ },
40
+ /** Deletes them from the device, and returns those there were. */
41
+ clear() {
42
+ try {
43
+ const ids = [read(localStorage, keys.visitor)?.id, read(sessionStorage, keys.visit)?.id].filter((id) => !!id);
44
+ localStorage.removeItem(keys.visitor);
45
+ sessionStorage.removeItem(keys.visit);
46
+ return ids;
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ },
52
+ };
53
+ }
54
+ function eventSender(url, fetcher, mode, ids) {
12
55
  let queue = [];
13
56
  let allowed = mode === "afterConsent" ? undefined : mode;
14
57
  let timer;
15
58
  let listening = false;
16
59
  let lastView = { objectID: "", at: 0 };
60
+ const send = (body) => {
61
+ // A beacon outlives the page; text/plain needs no CORS preflight.
62
+ if (fetcher || !(typeof navigator == "object" && navigator.sendBeacon?.(url, new Blob([body], { type: "text/plain" }))))
63
+ (fetcher ?? fetch)(url, { method: "POST", headers: { "content-type": "text/plain" }, body, keepalive: true })
64
+ .catch(() => undefined);
65
+ };
17
66
  const flush = () => {
18
67
  clearTimeout(timer);
19
68
  timer = undefined;
20
- while (allowed && queue.length) {
21
- const body = JSON.stringify({ events: queue.splice(0, 100) });
22
- // A beacon outlives the page; text/plain needs no CORS preflight.
23
- if (fetcher || !(typeof navigator == "object" && navigator.sendBeacon?.(url, new Blob([body], { type: "text/plain" }))))
24
- (fetcher ?? fetch)(url, { method: "POST", headers: { "content-type": "text/plain" }, body, keepalive: true })
25
- .catch(() => undefined);
26
- }
69
+ while (allowed && queue.length)
70
+ send(JSON.stringify({ ...ids?.use(), events: queue.splice(0, 100) }));
27
71
  };
28
72
  const add = (event) => {
29
73
  if (allowed === false)
@@ -52,8 +96,15 @@ function eventSender(url, fetcher, mode) {
52
96
  allowed = granted;
53
97
  if (granted)
54
98
  flush();
55
- else
99
+ else {
56
100
  queue = [];
101
+ ids?.clear();
102
+ }
103
+ },
104
+ forget() {
105
+ const forgotten = ids?.clear() ?? [];
106
+ if (forgotten.length)
107
+ send(JSON.stringify({ forget: forgotten }));
57
108
  },
58
109
  flush,
59
110
  };
@@ -68,7 +119,7 @@ export class SearchBrowserClient {
68
119
  throw new Error("A valid public index ID (pub_…) is required");
69
120
  this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}`;
70
121
  this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
71
- this.events = eventSender(options.eventsUrl ?? `${this.url}/events`, options.fetch, options.events ?? true);
122
+ this.events = eventSender(options.eventsUrl ?? `${this.url}/events`, options.fetch, options.events ?? true, options.visitorIds ? deviceIds(options.publicId) : null);
72
123
  }
73
124
  async post(operation, request, signal) {
74
125
  const response = await this.fetcher(`${this.url}/${operation}`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/browser",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Findlane browser SDK: fast, typo-tolerant public search from the browser with an index's public ID, plus React hooks and components.",
5
5
  "keywords": ["findlane", "search", "full-text search", "typo tolerance", "instant search", "ecommerce search", "browser", "react", "autocomplete"],
6
6
  "license": "MIT",