@findlane/server 0.7.0 → 0.8.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
@@ -31,13 +31,23 @@ The workspace ID is under Settings in the dashboard, and keys are under API keys
31
31
  - `search`: `search()`.
32
32
  - `write`: record writes, settings, public search restrictions, and indexing status.
33
33
 
34
+ ## Searching for a visitor
35
+
36
+ When your server searches for someone, such as a shopper loading a server-rendered search page, pass the visitor. The index's limits and bot rules then apply to them as they do to browser search, and searches from verified crawlers like Googlebot are free:
37
+
38
+ ```ts
39
+ const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent: req.get("user-agent") } })
40
+ ```
41
+
42
+ `suggest()` and `recommendations()` take the same option. Without it, a search counts as your server's own.
43
+
34
44
  ## An index's methods
35
45
 
36
46
  `client.index<T>(name)` returns these; `T` types your records, which each need a string `objectID`.
37
47
 
38
48
  | Method | What it does |
39
49
  | --- | --- |
40
- | `search(request)` | Search with a query, filters, facets, a sort, paging, and highlighting. |
50
+ | `search(request, options?)` | Search with a query, filters, facets, a sort, paging, and highlighting. `options` takes `signal` and `visitor` (below). |
41
51
  | `upsert(records)` | Add or update up to 1,000 records by `objectID`. |
42
52
  | `replaceAll(records)` | Replace the whole catalog in one request (up to 100,000 records). |
43
53
  | `replaceAllStaged(records, options)` | Replace a larger catalog in batches under one stage ID, switched in one commit. Retrying with the same `stageId` resumes. `abortReplacement(stageId)` drops a stage. |
package/dist/index.cjs CHANGED
@@ -67,6 +67,11 @@ class StagedReplacementError extends Error {
67
67
  this.name = "StagedReplacementError";
68
68
  }
69
69
  }
70
+ var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
71
+ var visitorHeaders = (visitor) => visitor?.ip ? {
72
+ "findlane-visitor-ip": visitor.ip.trim(),
73
+ ...visitor.userAgent ? { "findlane-visitor-agent": visitor.userAgent.replace(/[^\x20-\x7e]/g, "").slice(0, 512) } : {}
74
+ } : {};
70
75
 
71
76
  class FindlaneError extends Error {
72
77
  status;
@@ -93,12 +98,13 @@ class SearchServerClient {
93
98
  }
94
99
  index(name) {
95
100
  const path = `/api/teams/${encodeURIComponent(this.workspaceId)}/indexes/${encodeURIComponent(name)}`;
96
- const request = async (suffix, method = "GET", payload, signal) => {
101
+ const request = async (suffix, method = "GET", payload, signal, extra = {}) => {
97
102
  const response = await this.fetcher(this.baseUrl + path + suffix, {
98
103
  method,
99
104
  headers: {
100
105
  authorization: `Bearer ${this.key}`,
101
- ...payload === undefined ? {} : { "content-type": "application/json" }
106
+ ...payload === undefined ? {} : { "content-type": "application/json" },
107
+ ...extra
102
108
  },
103
109
  body: payload === undefined ? undefined : JSON.stringify(payload),
104
110
  signal
@@ -142,9 +148,18 @@ class SearchServerClient {
142
148
  return retry(() => request("/record-writes", "POST", payload, options.signal), options.signal);
143
149
  };
144
150
  return {
145
- search: (query = {}, signal) => request("/search", "POST", query, signal),
146
- suggest: async (query, signal) => (await request("/suggest", "POST", query, signal)).didYouMean,
147
- recommendations: (objectID, options = {}, signal) => request("/recommendations", "POST", { objectID, ...options }, signal),
151
+ search: (query = {}, options) => {
152
+ const { signal, visitor } = requestOptions(options);
153
+ return request("/search", "POST", query, signal, visitorHeaders(visitor));
154
+ },
155
+ suggest: async (query, options) => {
156
+ const { signal, visitor } = requestOptions(options);
157
+ return (await request("/suggest", "POST", query, signal, visitorHeaders(visitor))).didYouMean;
158
+ },
159
+ recommendations: (objectID, options = {}, requestOptionsOrSignal) => {
160
+ const { signal, visitor } = requestOptions(requestOptionsOrSignal);
161
+ return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
162
+ },
148
163
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
149
164
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
150
165
  upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
package/dist/index.d.cts CHANGED
@@ -240,6 +240,20 @@ export declare class StagedReplacementError extends Error {
240
240
  stageId: string;
241
241
  constructor(stageId: string, cause: unknown);
242
242
  }
243
+ /** The visitor a server searches for, such as a shopper loading a server-rendered search page. With it,
244
+ * the index's limits and bot rules apply to that visitor, as they do to browser search, and searches
245
+ * from verified crawlers like Googlebot are free. */
246
+ export type Visitor = {
247
+ /** The visitor's IP address: on Express `req.ip`, on Next.js the first address in `x-forwarded-for`. */
248
+ ip: string;
249
+ /** The visitor's User-Agent header. */
250
+ userAgent?: string;
251
+ };
252
+ /** Options for a search, a suggestion, or recommendations. */
253
+ export type RequestOptions = {
254
+ signal?: AbortSignal;
255
+ visitor?: Visitor;
256
+ };
243
257
  export declare class FindlaneError extends Error {
244
258
  status: number;
245
259
  constructor(status: number, message: string);
@@ -262,15 +276,16 @@ export declare class SearchServerClient {
262
276
  index<T extends {
263
277
  objectID: string;
264
278
  } = JsonRecord>(name: string): {
265
- /** Searches the index; the API key needs the `search` scope. */
266
- search: <R = T>(query?: SearchRequest, signal?: AbortSignal) => Promise<SearchResponse<R>>;
279
+ /** Searches the index; the API key needs the `search` scope. Pass `{ visitor }` when searching for
280
+ * someone, such as a shopper loading a server-rendered page (see Visitor). */
281
+ search: <R = T>(query?: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<SearchResponse<R>>;
267
282
  /** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the
268
283
  * same request (its q, filters, and fields matter). Needs the `didYouMean` setting; not counted as
269
284
  * a search. */
270
- suggest: (query: SearchRequest, signal?: AbortSignal) => Promise<DidYouMean | null>;
285
+ suggest: (query: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<DidYouMean | null>;
271
286
  /** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
272
287
  * The API key needs the `search` scope; not counted as a search. */
273
- recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, signal?: AbortSignal) => Promise<RecommendationsResponse<R>>;
288
+ recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<RecommendationsResponse<R>>;
274
289
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
275
290
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
276
291
  upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
package/dist/index.d.ts CHANGED
@@ -240,6 +240,20 @@ export declare class StagedReplacementError extends Error {
240
240
  stageId: string;
241
241
  constructor(stageId: string, cause: unknown);
242
242
  }
243
+ /** The visitor a server searches for, such as a shopper loading a server-rendered search page. With it,
244
+ * the index's limits and bot rules apply to that visitor, as they do to browser search, and searches
245
+ * from verified crawlers like Googlebot are free. */
246
+ export type Visitor = {
247
+ /** The visitor's IP address: on Express `req.ip`, on Next.js the first address in `x-forwarded-for`. */
248
+ ip: string;
249
+ /** The visitor's User-Agent header. */
250
+ userAgent?: string;
251
+ };
252
+ /** Options for a search, a suggestion, or recommendations. */
253
+ export type RequestOptions = {
254
+ signal?: AbortSignal;
255
+ visitor?: Visitor;
256
+ };
243
257
  export declare class FindlaneError extends Error {
244
258
  status: number;
245
259
  constructor(status: number, message: string);
@@ -262,15 +276,16 @@ export declare class SearchServerClient {
262
276
  index<T extends {
263
277
  objectID: string;
264
278
  } = JsonRecord>(name: string): {
265
- /** Searches the index; the API key needs the `search` scope. */
266
- search: <R = T>(query?: SearchRequest, signal?: AbortSignal) => Promise<SearchResponse<R>>;
279
+ /** Searches the index; the API key needs the `search` scope. Pass `{ visitor }` when searching for
280
+ * someone, such as a shopper loading a server-rendered page (see Visitor). */
281
+ search: <R = T>(query?: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<SearchResponse<R>>;
267
282
  /** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the
268
283
  * same request (its q, filters, and fields matter). Needs the `didYouMean` setting; not counted as
269
284
  * a search. */
270
- suggest: (query: SearchRequest, signal?: AbortSignal) => Promise<DidYouMean | null>;
285
+ suggest: (query: SearchRequest, options?: AbortSignal | RequestOptions) => Promise<DidYouMean | null>;
271
286
  /** A product's recommendations (the recommendations add-on): products that go with it, and similar products.
272
287
  * The API key needs the `search` scope; not counted as a search. */
273
- recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, signal?: AbortSignal) => Promise<RecommendationsResponse<R>>;
288
+ recommendations: <R = T>(objectID: string, options?: RecommendationsRequest, requestOptionsOrSignal?: AbortSignal | RequestOptions) => Promise<RecommendationsResponse<R>>;
274
289
  upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
275
290
  getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
276
291
  upsertOne: (record: T, options: ConditionalWriteOptions) => Promise<WriteReceipt>;
package/dist/index.js CHANGED
@@ -21,6 +21,11 @@ class StagedReplacementError extends Error {
21
21
  this.name = "StagedReplacementError";
22
22
  }
23
23
  }
24
+ var requestOptions = (value) => value === undefined ? {} : ("aborted" in value) ? { signal: value } : value;
25
+ var visitorHeaders = (visitor) => visitor?.ip ? {
26
+ "findlane-visitor-ip": visitor.ip.trim(),
27
+ ...visitor.userAgent ? { "findlane-visitor-agent": visitor.userAgent.replace(/[^\x20-\x7e]/g, "").slice(0, 512) } : {}
28
+ } : {};
24
29
 
25
30
  class FindlaneError extends Error {
26
31
  status;
@@ -47,12 +52,13 @@ class SearchServerClient {
47
52
  }
48
53
  index(name) {
49
54
  const path = `/api/teams/${encodeURIComponent(this.workspaceId)}/indexes/${encodeURIComponent(name)}`;
50
- const request = async (suffix, method = "GET", payload, signal) => {
55
+ const request = async (suffix, method = "GET", payload, signal, extra = {}) => {
51
56
  const response = await this.fetcher(this.baseUrl + path + suffix, {
52
57
  method,
53
58
  headers: {
54
59
  authorization: `Bearer ${this.key}`,
55
- ...payload === undefined ? {} : { "content-type": "application/json" }
60
+ ...payload === undefined ? {} : { "content-type": "application/json" },
61
+ ...extra
56
62
  },
57
63
  body: payload === undefined ? undefined : JSON.stringify(payload),
58
64
  signal
@@ -96,9 +102,18 @@ class SearchServerClient {
96
102
  return retry(() => request("/record-writes", "POST", payload, options.signal), options.signal);
97
103
  };
98
104
  return {
99
- search: (query = {}, signal) => request("/search", "POST", query, signal),
100
- suggest: async (query, signal) => (await request("/suggest", "POST", query, signal)).didYouMean,
101
- recommendations: (objectID, options = {}, signal) => request("/recommendations", "POST", { objectID, ...options }, signal),
105
+ search: (query = {}, options) => {
106
+ const { signal, visitor } = requestOptions(options);
107
+ return request("/search", "POST", query, signal, visitorHeaders(visitor));
108
+ },
109
+ suggest: async (query, options) => {
110
+ const { signal, visitor } = requestOptions(options);
111
+ return (await request("/suggest", "POST", query, signal, visitorHeaders(visitor))).didYouMean;
112
+ },
113
+ recommendations: (objectID, options = {}, requestOptionsOrSignal) => {
114
+ const { signal, visitor } = requestOptions(requestOptionsOrSignal);
115
+ return request("/recommendations", "POST", { objectID, ...options }, signal, visitorHeaders(visitor));
116
+ },
102
117
  upsert: (records, signal) => request("/records", "POST", { records }, signal),
103
118
  getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
104
119
  upsertOne: (record, options) => writeOne("upsert", record.objectID, record, options),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/server",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",