@findlane/server 0.9.0 → 0.11.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 +2 -0
- package/dist/index.cjs +46 -1
- package/dist/index.d.cts +26 -1
- package/dist/index.d.ts +26 -1
- package/dist/index.js +46 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,6 +57,8 @@ 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)`, `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. |
|
|
61
|
+
| `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
62
|
|
|
61
63
|
### Settings
|
|
62
64
|
|
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) } : {}
|
|
@@ -164,6 +176,24 @@ class SearchServerClient {
|
|
|
164
176
|
await pause(wait, signal);
|
|
165
177
|
}
|
|
166
178
|
};
|
|
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
|
+
};
|
|
167
197
|
const sendEvents = async (events, signal) => {
|
|
168
198
|
const result = { accepted: 0, rejected: [] };
|
|
169
199
|
let batch = [];
|
|
@@ -219,7 +249,22 @@ class SearchServerClient {
|
|
|
219
249
|
...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
|
|
220
250
|
};
|
|
221
251
|
}(), signal),
|
|
222
|
-
cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal)
|
|
252
|
+
cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal),
|
|
253
|
+
proxy: async (request2, visitor = {}) => {
|
|
254
|
+
if (request2.method !== "POST")
|
|
255
|
+
return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
|
|
256
|
+
const tooLarge = () => jsonResponse(JSON.stringify({ error: "The request is too large" }), 413);
|
|
257
|
+
if (Number(request2.headers.get("content-length") ?? 0) > relayedBytes)
|
|
258
|
+
return tooLarge();
|
|
259
|
+
const body = await request2.text();
|
|
260
|
+
if (new TextEncoder().encode(body).length > relayedBytes)
|
|
261
|
+
return tooLarge();
|
|
262
|
+
const found = { ...requestVisitor(request2), ...visitor };
|
|
263
|
+
if (!found.ip)
|
|
264
|
+
throw new Error("No visitor IP address in the request's headers; pass it: events.proxy(request, { ip })");
|
|
265
|
+
return relayEvents(body, { ...found, ip: found.ip });
|
|
266
|
+
},
|
|
267
|
+
relay: (body, visitor, signal) => relayEvents(body, visitor, signal)
|
|
223
268
|
},
|
|
224
269
|
upsert: (records, signal) => request("/records", "POST", { records }, signal),
|
|
225
270
|
getRecord: (objectID, signal) => request(`/records/${encodeURIComponent(objectID)}`, "GET", undefined, signal),
|
package/dist/index.d.cts
CHANGED
|
@@ -189,14 +189,23 @@ export type RecommendationHit<T> = {
|
|
|
189
189
|
score: number | null;
|
|
190
190
|
record: T;
|
|
191
191
|
};
|
|
192
|
+
/** A Bought together pick: `count` is the orders in the last year that had both products. */
|
|
193
|
+
export type TogetherHit<T> = RecommendationHit<T> & {
|
|
194
|
+
count: number;
|
|
195
|
+
};
|
|
192
196
|
export type RecommendationsResponse<T> = {
|
|
193
197
|
objectID: string;
|
|
194
|
-
/** false until the product's lists are built;
|
|
198
|
+
/** false until the product's lists are built; the lists are empty meanwhile. */
|
|
195
199
|
ready: boolean;
|
|
196
200
|
/** Products a shopper would want with this one, the likeliest first; `score` is the decision model's probability. */
|
|
197
201
|
goesWith: RecommendationHit<T>[];
|
|
198
202
|
/** Products a shopper could choose instead, then (unless the index hides them) variants of this one. */
|
|
199
203
|
similar: RecommendationHit<T>[];
|
|
204
|
+
/** Products bought in the same orders as this one, the most orders first, from the orders sent with
|
|
205
|
+
* `index.events`. Empty without orders. */
|
|
206
|
+
together?: TogetherHit<T>[];
|
|
207
|
+
/** What `together`'s counts are of. */
|
|
208
|
+
togetherBasis?: "orders";
|
|
200
209
|
};
|
|
201
210
|
/** A corrected query. `applied`: the results are already for `q` (a search's "Showing results for");
|
|
202
211
|
* otherwise searching `q` finds results that the query didn't ("Did you mean"). `changes` lists each
|
|
@@ -254,6 +263,13 @@ export type RequestOptions = {
|
|
|
254
263
|
signal?: AbortSignal;
|
|
255
264
|
visitor?: Visitor;
|
|
256
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
|
+
};
|
|
257
273
|
/** A product in a cart or an order. */
|
|
258
274
|
export type EventItem = {
|
|
259
275
|
objectID: string;
|
|
@@ -348,6 +364,15 @@ export declare class SearchServerClient {
|
|
|
348
364
|
importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
|
|
349
365
|
/** Takes a cancelled or refunded order out of Bought together. */
|
|
350
366
|
cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
|
|
367
|
+
/** A route on your own domain for the browser SDK's events (its `eventsUrl` option): ad blockers
|
|
368
|
+
* block beacons to other sites, not to the page's own. Give it the browser's POST request (Next.js
|
|
369
|
+
* route handlers, Workers, Hono, Bun, Deno); it finds the visitor's address in the headers
|
|
370
|
+
* Cloudflare, Vercel and most proxies set. Where a visitor reaches your server directly, they can
|
|
371
|
+
* set those too, so pass `{ ip }`. The API key needs the `events` or `write` scope. */
|
|
372
|
+
proxy: (request: Request, visitor?: Partial<RelayedVisitor>) => Promise<Response>;
|
|
373
|
+
/** `proxy` for servers without Fetch API requests, such as Express: the request's body as text,
|
|
374
|
+
* and its visitor. Answers with the API's response. */
|
|
375
|
+
relay: (body: string, visitor: RelayedVisitor, signal?: AbortSignal) => Promise<Response>;
|
|
351
376
|
};
|
|
352
377
|
upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
353
378
|
getRecord: (objectID: string, signal?: AbortSignal) => Promise<RecordSnapshot<T>>;
|
package/dist/index.d.ts
CHANGED
|
@@ -189,14 +189,23 @@ export type RecommendationHit<T> = {
|
|
|
189
189
|
score: number | null;
|
|
190
190
|
record: T;
|
|
191
191
|
};
|
|
192
|
+
/** A Bought together pick: `count` is the orders in the last year that had both products. */
|
|
193
|
+
export type TogetherHit<T> = RecommendationHit<T> & {
|
|
194
|
+
count: number;
|
|
195
|
+
};
|
|
192
196
|
export type RecommendationsResponse<T> = {
|
|
193
197
|
objectID: string;
|
|
194
|
-
/** false until the product's lists are built;
|
|
198
|
+
/** false until the product's lists are built; the lists are empty meanwhile. */
|
|
195
199
|
ready: boolean;
|
|
196
200
|
/** Products a shopper would want with this one, the likeliest first; `score` is the decision model's probability. */
|
|
197
201
|
goesWith: RecommendationHit<T>[];
|
|
198
202
|
/** Products a shopper could choose instead, then (unless the index hides them) variants of this one. */
|
|
199
203
|
similar: RecommendationHit<T>[];
|
|
204
|
+
/** Products bought in the same orders as this one, the most orders first, from the orders sent with
|
|
205
|
+
* `index.events`. Empty without orders. */
|
|
206
|
+
together?: TogetherHit<T>[];
|
|
207
|
+
/** What `together`'s counts are of. */
|
|
208
|
+
togetherBasis?: "orders";
|
|
200
209
|
};
|
|
201
210
|
/** A corrected query. `applied`: the results are already for `q` (a search's "Showing results for");
|
|
202
211
|
* otherwise searching `q` finds results that the query didn't ("Did you mean"). `changes` lists each
|
|
@@ -254,6 +263,13 @@ export type RequestOptions = {
|
|
|
254
263
|
signal?: AbortSignal;
|
|
255
264
|
visitor?: Visitor;
|
|
256
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
|
+
};
|
|
257
273
|
/** A product in a cart or an order. */
|
|
258
274
|
export type EventItem = {
|
|
259
275
|
objectID: string;
|
|
@@ -348,6 +364,15 @@ export declare class SearchServerClient {
|
|
|
348
364
|
importOrders: (orders: Iterable<Order> | AsyncIterable<Order>, signal?: AbortSignal) => Promise<EventsResult>;
|
|
349
365
|
/** Takes a cancelled or refunded order out of Bought together. */
|
|
350
366
|
cancelOrder: (orderID: string, signal?: AbortSignal) => Promise<EventsResult>;
|
|
367
|
+
/** A route on your own domain for the browser SDK's events (its `eventsUrl` option): ad blockers
|
|
368
|
+
* block beacons to other sites, not to the page's own. Give it the browser's POST request (Next.js
|
|
369
|
+
* route handlers, Workers, Hono, Bun, Deno); it finds the visitor's address in the headers
|
|
370
|
+
* Cloudflare, Vercel and most proxies set. Where a visitor reaches your server directly, they can
|
|
371
|
+
* set those too, so pass `{ ip }`. The API key needs the `events` or `write` scope. */
|
|
372
|
+
proxy: (request: Request, visitor?: Partial<RelayedVisitor>) => Promise<Response>;
|
|
373
|
+
/** `proxy` for servers without Fetch API requests, such as Express: the request's body as text,
|
|
374
|
+
* and its visitor. Answers with the API's response. */
|
|
375
|
+
relay: (body: string, visitor: RelayedVisitor, signal?: AbortSignal) => Promise<Response>;
|
|
351
376
|
};
|
|
352
377
|
upsert: (records: T[], signal?: AbortSignal) => Promise<WriteReceipt>;
|
|
353
378
|
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) } : {}
|
|
@@ -118,6 +130,24 @@ class SearchServerClient {
|
|
|
118
130
|
await pause(wait, signal);
|
|
119
131
|
}
|
|
120
132
|
};
|
|
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
|
+
};
|
|
121
151
|
const sendEvents = async (events, signal) => {
|
|
122
152
|
const result = { accepted: 0, rejected: [] };
|
|
123
153
|
let batch = [];
|
|
@@ -173,7 +203,22 @@ class SearchServerClient {
|
|
|
173
203
|
...timestamp === undefined ? {} : { timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime() }
|
|
174
204
|
};
|
|
175
205
|
}(), signal),
|
|
176
|
-
cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal)
|
|
206
|
+
cancelOrder: (orderID, signal) => sendEvents([{ type: "cancel", orderID }], signal),
|
|
207
|
+
proxy: async (request2, visitor = {}) => {
|
|
208
|
+
if (request2.method !== "POST")
|
|
209
|
+
return jsonResponse(JSON.stringify({ error: "Only POST is allowed" }), 405, { allow: "POST" });
|
|
210
|
+
const tooLarge = () => jsonResponse(JSON.stringify({ error: "The request is too large" }), 413);
|
|
211
|
+
if (Number(request2.headers.get("content-length") ?? 0) > relayedBytes)
|
|
212
|
+
return tooLarge();
|
|
213
|
+
const body = await request2.text();
|
|
214
|
+
if (new TextEncoder().encode(body).length > relayedBytes)
|
|
215
|
+
return tooLarge();
|
|
216
|
+
const found = { ...requestVisitor(request2), ...visitor };
|
|
217
|
+
if (!found.ip)
|
|
218
|
+
throw new Error("No visitor IP address in the request's headers; pass it: events.proxy(request, { ip })");
|
|
219
|
+
return relayEvents(body, { ...found, ip: found.ip });
|
|
220
|
+
},
|
|
221
|
+
relay: (body, visitor, signal) => relayEvents(body, visitor, signal)
|
|
177
222
|
},
|
|
178
223
|
upsert: (records, signal) => request("/records", "POST", { records }, signal),
|
|
179
224
|
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.
|
|
3
|
+
"version": "0.11.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",
|