@findlane/server 0.10.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
@@ -57,6 +57,9 @@ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent:
57
57
  | `getSettings()`, `replaceSettings(settings)`, `waitForSettings()` | Read and change searchable fields, facets, filters, sorts, custom ranking, synonyms, languages, typo tolerance, and text normalization. |
58
58
  | `getIndexingStatus()`, `waitUntilIndexed(revision)` | Writes return an accepted revision and are searchable when accepted, so `waitUntilIndexed` returns at once. |
59
59
  | `getPublicSearchAccess()`, `setPublicSearchPolicy(policy)`, `usePublicIndexDefaults()` | Restrict what browser search may read and query. |
60
+ | `events.send(events, { visitorId, visitId }?)`, `events.importOrders(orders)`, `events.cancelOrder(orderID)` | Orders and other events for the index's Events page and Bought together; the key needs the `events` or `write` scope. Your own anonymous visitor IDs link a shopper's events, when the index takes them. |
61
+ | `events.forget(ids)` | Deletes the events sent with these visitor or visit IDs, within a week. |
62
+ | `events.proxy(request)`, `events.relay(body, visitor)` | A route on your own site for the browser SDK's events (its `eventsUrl`), which ad blockers let through. See [Events](https://findlane.dev/docs/events#through-your-own-domain). |
60
63
 
61
64
  ### Settings
62
65
 
package/dist/index.cjs CHANGED
@@ -69,6 +69,18 @@ class StagedReplacementError extends Error {
69
69
  }
70
70
  var eventsPerRequest = 200;
71
71
  var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
72
+ var relayedBytes = 256 * 1024;
73
+ function requestVisitor(request) {
74
+ const headers = request.headers;
75
+ const cf = request.cf;
76
+ return {
77
+ ip: headers.get("cf-connecting-ip") ?? headers.get("x-real-ip") ?? headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
78
+ userAgent: headers.get("user-agent") ?? undefined,
79
+ origin: headers.get("origin") ?? new URL(request.url).origin,
80
+ country: (typeof cf?.country == "string" ? cf.country : null) ?? headers.get("cf-ipcountry") ?? headers.get("x-vercel-ip-country") ?? undefined
81
+ };
82
+ }
83
+ var jsonResponse = (body, status, headers = {}) => new Response(body, { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...headers } });
72
84
  var visitorHeaders = (visitor) => visitor?.ip ? {
73
85
  "findlane-visitor-ip": visitor.ip.trim(),
74
86
  ...visitor.userAgent ? { "findlane-visitor-agent": visitor.userAgent.replace(/[^\x20-\x7e]/g, "").slice(0, 512) } : {}
@@ -137,7 +149,7 @@ class SearchServerClient {
137
149
  }
138
150
  }
139
151
  };
140
- const postEvents = async (events, signal) => {
152
+ const postEvents = async (payload, signal) => {
141
153
  for (let attempt = 0;; attempt++) {
142
154
  signal?.throwIfAborted();
143
155
  let wait = 250 * 2 ** attempt;
@@ -146,11 +158,11 @@ class SearchServerClient {
146
158
  method: "POST",
147
159
  signal,
148
160
  headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
149
- body: JSON.stringify({ events })
161
+ body: JSON.stringify(payload)
150
162
  });
151
163
  const data = await response.json().catch(() => ({}));
152
164
  if (response.ok)
153
- return { accepted: data.accepted ?? 0, rejected: data.rejected ?? [] };
165
+ return data;
154
166
  const error = new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
155
167
  if (attempt >= 5 || ![408, 429, 502, 503, 504].includes(response.status))
156
168
  throw error;
@@ -164,16 +176,41 @@ class SearchServerClient {
164
176
  await pause(wait, signal);
165
177
  }
166
178
  };
167
- const sendEvents = async (events, signal) => {
179
+ const relayEvents = async (body, visitor, signal) => {
180
+ if (!visitor.ip?.trim())
181
+ throw new Error("Relaying events needs the visitor's IP address");
182
+ const response = await this.fetcher(this.baseUrl + path + "/events", {
183
+ method: "POST",
184
+ signal,
185
+ body,
186
+ headers: {
187
+ authorization: `Bearer ${this.key}`,
188
+ "content-type": "application/json",
189
+ ...visitorHeaders(visitor),
190
+ ...visitor.origin ? { "findlane-visitor-origin": visitor.origin } : {},
191
+ ...visitor.country ? { "findlane-visitor-country": visitor.country } : {}
192
+ }
193
+ });
194
+ const retryAfter = response.headers.get("retry-after");
195
+ return jsonResponse(await response.text(), response.status, retryAfter ? { "retry-after": retryAfter } : {});
196
+ };
197
+ const sendEvents = async (events, options = {}) => {
198
+ const { signal, visitorId, visitId } = options;
168
199
  const result = { accepted: 0, rejected: [] };
169
200
  let batch = [];
170
201
  let sent = 0;
171
202
  const flush = async () => {
172
203
  if (!batch.length)
173
204
  return;
174
- const answer = await postEvents(batch, signal);
175
- result.accepted += answer.accepted;
176
- result.rejected.push(...answer.rejected.map((item) => ({ ...item, index: item.index + sent })));
205
+ const answer = await postEvents({
206
+ ...visitorId === undefined ? {} : { visitorId },
207
+ ...visitId === undefined ? {} : { visitId },
208
+ events: batch
209
+ }, signal);
210
+ result.accepted += answer.accepted ?? 0;
211
+ result.rejected.push(...(answer.rejected ?? []).map((item) => ({ ...item, index: item.index + sent })));
212
+ if (answer.visitorIds === "off")
213
+ result.visitorIds = "off";
177
214
  sent += batch.length;
178
215
  batch = [];
179
216
  };
@@ -210,7 +247,7 @@ class SearchServerClient {
210
247
  return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
211
248
  },
212
249
  events: {
213
- send: (events, signal) => sendEvents(events, signal),
250
+ send: (events, options) => sendEvents(events, options === undefined ? {} : ("aborted" in options) ? { signal: options } : options),
214
251
  importOrders: (orders, signal) => sendEvents(async function* () {
215
252
  for await (const { timestamp, ...order } of orders)
216
253
  yield {
@@ -218,8 +255,29 @@ class SearchServerClient {
218
255
  ...order,
219
256
  ...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
220
257
  };
221
- }(), signal),
222
- cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal)
258
+ }(), { signal }),
259
+ cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], { signal }),
260
+ forget: async (ids, signal) => {
261
+ let forgotten = 0;
262
+ for (let start = 0;start < ids.length; start += 1000)
263
+ forgotten += (await postEvents({ forget: ids.slice(start, start + 1000) }, signal)).forgotten ?? 0;
264
+ return { forgotten };
265
+ },
266
+ proxy: async (request2, visitor = {}) => {
267
+ if (request2.method !== "POST")
268
+ return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
269
+ const tooLarge = () => jsonResponse(JSON.stringify({ error: "The request is too large" }), 413);
270
+ if (Number(request2.headers.get("content-length") ?? 0) > relayedBytes)
271
+ return tooLarge();
272
+ const body = await request2.text();
273
+ if (new TextEncoder().encode(body).length > relayedBytes)
274
+ return tooLarge();
275
+ const found = { ...requestVisitor(request2), ...visitor };
276
+ if (!found.ip)
277
+ throw new Error("No visitor IP address in the request's headers; pass it: events.proxy(request, { ip })");
278
+ return relayEvents(body, { ...found, ip: found.ip });
279
+ },
280
+ relay: (body, visitor, signal) => relayEvents(body, visitor, signal)
223
281
  },
224
282
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
225
283
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
package/dist/index.d.cts CHANGED
@@ -263,6 +263,13 @@ export type RequestOptions = {
263
263
  signal?: AbortSignal;
264
264
  visitor?: Visitor;
265
265
  };
266
+ /** A visitor whose events a server relays from its own domain (`events.relay`). */
267
+ export type RelayedVisitor = Visitor & {
268
+ /** The page's origin, such as `https://shop.example`: the site the Events page shows. */
269
+ origin?: string;
270
+ /** The visitor's country as two letters, such as `IS`, when the host knows it. */
271
+ country?: string;
272
+ };
266
273
  /** A product in a cart or an order. */
267
274
  export type EventItem = {
268
275
  objectID: string;
@@ -307,13 +314,23 @@ export type Order = {
307
314
  currency?: string;
308
315
  timestamp?: number | Date | string;
309
316
  };
310
- /** How many events were accepted, and which were refused and why; `index` counts from the first event sent. */
317
+ /** How many events were accepted, and which were refused and why; `index` counts from the first event
318
+ * sent. `visitorIds: "off"` when IDs were sent and dropped: the index doesn't take them. */
311
319
  export type EventsResult = {
312
320
  accepted: number;
313
321
  rejected: {
314
322
  index: number;
315
323
  reason: string;
316
324
  }[];
325
+ visitorIds?: "off";
326
+ };
327
+ /** Options for `events.send`. `visitorId` and `visitId` are your own anonymous IDs for the shopper and
328
+ * the visit, for all the events sent: 16 to 64 letters, digits, - or _, never an email address, a
329
+ * phone number or a customer number. They're kept only when the index takes visitor IDs. */
330
+ export type EventOptions = {
331
+ signal?: AbortSignal;
332
+ visitorId?: string;
333
+ visitId?: string;
317
334
  };
318
335
  export declare class FindlaneError extends Error {
319
336
  status: number;
@@ -350,13 +367,28 @@ export declare class SearchServerClient {
350
367
  /** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
351
368
  * scope. Orders count once each, however often they're sent. */
352
369
  events: {
353
- /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. */
354
- send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
370
+ /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. Pass
371
+ * `{ visitorId, visitId }` to link a shopper's events (see EventOptions). */
372
+ send: (events: ServerEvent[], options?: AbortSignal | EventOptions) => Promise<EventsResult>;
355
373
  /** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
356
374
  * cursor), 200 a request. */
357
375
  importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
358
376
  /** Takes a cancelled or refunded order out of Bought together. */
359
377
  cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
378
+ /** Deletes the events sent with these visitor or visit IDs, for shoppers who asked: Findlane does it
379
+ * within a week. 1,000 IDs a request. */
380
+ forget: (ids: string[], signal?: AbortSignal) => Promise<{
381
+ forgotten: number;
382
+ }>;
383
+ /** A route on your own domain for the browser SDK's events (its `eventsUrl` option): ad blockers
384
+ * block beacons to other sites, not to the page's own. Give it the browser's POST request (Next.js
385
+ * route handlers, Workers, Hono, Bun, Deno); it finds the visitor's address in the headers
386
+ * Cloudflare, Vercel and most proxies set. Where a visitor reaches your server directly, they can
387
+ * set those too, so pass `{ ip }`. The API key needs the `events` or `write` scope. */
388
+ proxy: (request: Request, visitor?: Partial<RelayedVisitor>) => Promise<Response>;
389
+ /** `proxy` for servers without Fetch API requests, such as Express: the request's body as text,
390
+ * and its visitor. Answers with the API's response. */
391
+ relay: (body: string, visitor: RelayedVisitor, signal?: AbortSignal) => Promise<Response>;
360
392
  };
361
393
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
362
394
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
package/dist/index.d.ts CHANGED
@@ -263,6 +263,13 @@ export type RequestOptions = {
263
263
  signal?: AbortSignal;
264
264
  visitor?: Visitor;
265
265
  };
266
+ /** A visitor whose events a server relays from its own domain (`events.relay`). */
267
+ export type RelayedVisitor = Visitor & {
268
+ /** The page's origin, such as `https://shop.example`: the site the Events page shows. */
269
+ origin?: string;
270
+ /** The visitor's country as two letters, such as `IS`, when the host knows it. */
271
+ country?: string;
272
+ };
266
273
  /** A product in a cart or an order. */
267
274
  export type EventItem = {
268
275
  objectID: string;
@@ -307,13 +314,23 @@ export type Order = {
307
314
  currency?: string;
308
315
  timestamp?: number | Date | string;
309
316
  };
310
- /** How many events were accepted, and which were refused and why; `index` counts from the first event sent. */
317
+ /** How many events were accepted, and which were refused and why; `index` counts from the first event
318
+ * sent. `visitorIds: "off"` when IDs were sent and dropped: the index doesn't take them. */
311
319
  export type EventsResult = {
312
320
  accepted: number;
313
321
  rejected: {
314
322
  index: number;
315
323
  reason: string;
316
324
  }[];
325
+ visitorIds?: "off";
326
+ };
327
+ /** Options for `events.send`. `visitorId` and `visitId` are your own anonymous IDs for the shopper and
328
+ * the visit, for all the events sent: 16 to 64 letters, digits, - or _, never an email address, a
329
+ * phone number or a customer number. They're kept only when the index takes visitor IDs. */
330
+ export type EventOptions = {
331
+ signal?: AbortSignal;
332
+ visitorId?: string;
333
+ visitId?: string;
317
334
  };
318
335
  export declare class FindlaneError extends Error {
319
336
  status: number;
@@ -350,13 +367,28 @@ export declare class SearchServerClient {
350
367
  /** Events for the index's Events page and Bought together; the API key needs the `events` or `write`
351
368
  * scope. Orders count once each, however often they're sent. */
352
369
  events: {
353
- /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. */
354
- send: (events: ServerEvent[], signal?: AbortSignal) => Promise<EventsResult>;
370
+ /** Sends events, 200 a request. One bad event doesn't refuse the rest: they're in `rejected`. Pass
371
+ * `{ visitorId, visitId }` to link a shopper's events (see EventOptions). */
372
+ send: (events: ServerEvent[], options?: AbortSignal | EventOptions) => Promise<EventsResult>;
355
373
  /** Sends past orders, up to 400 days old, from a list or an async iterable (such as a database
356
374
  * cursor), 200 a request. */
357
375
  importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
358
376
  /** Takes a cancelled or refunded order out of Bought together. */
359
377
  cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
378
+ /** Deletes the events sent with these visitor or visit IDs, for shoppers who asked: Findlane does it
379
+ * within a week. 1,000 IDs a request. */
380
+ forget: (ids: string[], signal?: AbortSignal) => Promise<{
381
+ forgotten: number;
382
+ }>;
383
+ /** A route on your own domain for the browser SDK's events (its `eventsUrl` option): ad blockers
384
+ * block beacons to other sites, not to the page's own. Give it the browser's POST request (Next.js
385
+ * route handlers, Workers, Hono, Bun, Deno); it finds the visitor's address in the headers
386
+ * Cloudflare, Vercel and most proxies set. Where a visitor reaches your server directly, they can
387
+ * set those too, so pass `{ ip }`. The API key needs the `events` or `write` scope. */
388
+ proxy: (request: Request, visitor?: Partial<RelayedVisitor>) => Promise<Response>;
389
+ /** `proxy` for servers without Fetch API requests, such as Express: the request's body as text,
390
+ * and its visitor. Answers with the API's response. */
391
+ relay: (body: string, visitor: RelayedVisitor, signal?: AbortSignal) => Promise<Response>;
360
392
  };
361
393
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
362
394
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
package/dist/index.js CHANGED
@@ -23,6 +23,18 @@ class StagedReplacementError extends Error {
23
23
  }
24
24
  var eventsPerRequest = 200;
25
25
  var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
26
+ var relayedBytes = 256 * 1024;
27
+ function requestVisitor(request) {
28
+ const headers = request.headers;
29
+ const cf = request.cf;
30
+ return {
31
+ ip: headers.get("cf-connecting-ip") ?? headers.get("x-real-ip") ?? headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
32
+ userAgent: headers.get("user-agent") ?? undefined,
33
+ origin: headers.get("origin") ?? new URL(request.url).origin,
34
+ country: (typeof cf?.country == "string" ? cf.country : null) ?? headers.get("cf-ipcountry") ?? headers.get("x-vercel-ip-country") ?? undefined
35
+ };
36
+ }
37
+ var jsonResponse = (body, status, headers = {}) => new Response(body, { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...headers } });
26
38
  var visitorHeaders = (visitor) => visitor?.ip ? {
27
39
  "findlane-visitor-ip": visitor.ip.trim(),
28
40
  ...visitor.userAgent ? { "findlane-visitor-agent": visitor.userAgent.replace(/[^\x20-\x7e]/g, "").slice(0, 512) } : {}
@@ -91,7 +103,7 @@ class SearchServerClient {
91
103
  }
92
104
  }
93
105
  };
94
- const postEvents = async (events, signal) => {
106
+ const postEvents = async (payload, signal) => {
95
107
  for (let attempt = 0;; attempt++) {
96
108
  signal?.throwIfAborted();
97
109
  let wait = 250 * 2 ** attempt;
@@ -100,11 +112,11 @@ class SearchServerClient {
100
112
  method: "POST",
101
113
  signal,
102
114
  headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
103
- body: JSON.stringify({ events })
115
+ body: JSON.stringify(payload)
104
116
  });
105
117
  const data = await response.json().catch(() => ({}));
106
118
  if (response.ok)
107
- return { accepted: data.accepted ?? 0, rejected: data.rejected ?? [] };
119
+ return data;
108
120
  const error = new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
109
121
  if (attempt >= 5 || ![408, 429, 502, 503, 504].includes(response.status))
110
122
  throw error;
@@ -118,16 +130,41 @@ class SearchServerClient {
118
130
  await pause(wait, signal);
119
131
  }
120
132
  };
121
- const sendEvents = async (events, signal) => {
133
+ const relayEvents = async (body, visitor, signal) => {
134
+ if (!visitor.ip?.trim())
135
+ throw new Error("Relaying events needs the visitor's IP address");
136
+ const response = await this.fetcher(this.baseUrl + path + "/events", {
137
+ method: "POST",
138
+ signal,
139
+ body,
140
+ headers: {
141
+ authorization: `Bearer ${this.key}`,
142
+ "content-type": "application/json",
143
+ ...visitorHeaders(visitor),
144
+ ...visitor.origin ? { "findlane-visitor-origin": visitor.origin } : {},
145
+ ...visitor.country ? { "findlane-visitor-country": visitor.country } : {}
146
+ }
147
+ });
148
+ const retryAfter = response.headers.get("retry-after");
149
+ return jsonResponse(await response.text(), response.status, retryAfter ? { "retry-after": retryAfter } : {});
150
+ };
151
+ const sendEvents = async (events, options = {}) => {
152
+ const { signal, visitorId, visitId } = options;
122
153
  const result = { accepted: 0, rejected: [] };
123
154
  let batch = [];
124
155
  let sent = 0;
125
156
  const flush = async () => {
126
157
  if (!batch.length)
127
158
  return;
128
- const answer = await postEvents(batch, signal);
129
- result.accepted += answer.accepted;
130
- result.rejected.push(...answer.rejected.map((item) => ({ ...item, index: item.index + sent })));
159
+ const answer = await postEvents({
160
+ ...visitorId === undefined ? {} : { visitorId },
161
+ ...visitId === undefined ? {} : { visitId },
162
+ events: batch
163
+ }, signal);
164
+ result.accepted += answer.accepted ?? 0;
165
+ result.rejected.push(...(answer.rejected ?? []).map((item) => ({ ...item, index: item.index + sent })));
166
+ if (answer.visitorIds === "off")
167
+ result.visitorIds = "off";
131
168
  sent += batch.length;
132
169
  batch = [];
133
170
  };
@@ -164,7 +201,7 @@ class SearchServerClient {
164
201
  return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
165
202
  },
166
203
  events: {
167
- send: (events, signal) => sendEvents(events, signal),
204
+ send: (events, options) => sendEvents(events, options === undefined ? {} : ("aborted" in options) ? { signal: options } : options),
168
205
  importOrders: (orders, signal) => sendEvents(async function* () {
169
206
  for await (const { timestamp, ...order } of orders)
170
207
  yield {
@@ -172,8 +209,29 @@ class SearchServerClient {
172
209
  ...order,
173
210
  ...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
174
211
  };
175
- }(), signal),
176
- cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal)
212
+ }(), { signal }),
213
+ cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], { signal }),
214
+ forget: async (ids, signal) => {
215
+ let forgotten = 0;
216
+ for (let start = 0;start < ids.length; start += 1000)
217
+ forgotten += (await postEvents({ forget: ids.slice(start, start + 1000) }, signal)).forgotten ?? 0;
218
+ return { forgotten };
219
+ },
220
+ proxy: async (request2, visitor = {}) => {
221
+ if (request2.method !== "POST")
222
+ return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
223
+ const tooLarge = () => jsonResponse(JSON.stringify({ error: "The request is too large" }), 413);
224
+ if (Number(request2.headers.get("content-length") ?? 0) > relayedBytes)
225
+ return tooLarge();
226
+ const body = await request2.text();
227
+ if (new TextEncoder().encode(body).length > relayedBytes)
228
+ return tooLarge();
229
+ const found = { ...requestVisitor(request2), ...visitor };
230
+ if (!found.ip)
231
+ throw new Error("No visitor IP address in the request's headers; pass it: events.proxy(request, { ip })");
232
+ return relayEvents(body, { ...found, ip: found.ip });
233
+ },
234
+ relay: (body, visitor, signal) => relayEvents(body, visitor, signal)
177
235
  },
178
236
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
179
237
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/server",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Findlane server SDK: upload catalogs, manage index settings, and search with a secret workspace API key.",
5
5
  "keywords": ["findlane", "search", "full-text search", "typo tolerance", "ecommerce search", "search api", "sdk"],
6
6
  "license": "MIT",