@findlane/browser 0.4.2 → 0.6.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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type SearchRequest = {
2
2
  q?: string;
3
- /** A sort ID from the index settings, or "relevance". */
3
+ /** A sort ID from the index settings, "relevance", or "distance" (with `near`). */
4
4
  sort?: string;
5
5
  /** Field values to keep: a value, `{ in: [...] }`, or a range `{ gte, lte }` for numbers and dates. */
6
6
  filters?: Record<string, unknown>;
@@ -26,12 +26,40 @@ export type SearchRequest = {
26
26
  preTag?: string;
27
27
  postTag?: string;
28
28
  };
29
+ /** Nearest first from a point, with each hit's `distance` in meters; `radius` (meters) keeps only what's
30
+ * within it. With a query, the best matches come first and distance orders them. Needs the index's
31
+ * location field. Records without a location are left out.
32
+ * `{ visitor: true }` uses the visitor's approximate location from their IP address (about their city's
33
+ * center); `near` in the response says where that was, or null when it isn't known. */
34
+ near?: GeoPoint & {
35
+ radius?: number;
36
+ } | {
37
+ visitor: true;
38
+ radius?: number;
39
+ };
40
+ /** Only records inside a box, in degrees, such as a map's visible area. West greater than east crosses the
41
+ * antimeridian. */
42
+ within?: GeoBox;
43
+ };
44
+ /** A point in degrees: lat from -90 to 90, lng from -180 to 180. */
45
+ export type GeoPoint = {
46
+ lat: number;
47
+ lng: number;
48
+ };
49
+ /** A box in degrees, such as a map's bounds. */
50
+ export type GeoBox = {
51
+ north: number;
52
+ south: number;
53
+ east: number;
54
+ west: number;
29
55
  };
30
56
  export type SearchResponse<T> = {
31
57
  hits: {
32
58
  id: string;
33
59
  score: number | null;
34
60
  record: T;
61
+ /** With `near`: meters from the point. */
62
+ distance?: number;
35
63
  /** With `highlight`: matched searchable fields by path; list fields give one string per value. */
36
64
  highlights?: Record<string, string | string[]>;
37
65
  }[];
@@ -48,6 +76,8 @@ export type SearchResponse<T> = {
48
76
  }>;
49
77
  tookMs: number;
50
78
  sort?: string;
79
+ /** With `near`: the point distances are from, or null when the visitor's location isn't known. */
80
+ near?: GeoPoint | null;
51
81
  /** With the index's `didYouMean` setting: the query with the words typo tolerance corrected, when the
52
82
  * results matched some words only by typo ("Showing results for …"), else null. */
53
83
  didYouMean?: DidYouMean | null;
package/dist/index.js CHANGED
@@ -20,7 +20,9 @@ export class SearchBrowserClient {
20
20
  async post(operation, request, signal) {
21
21
  const response = await this.fetcher(`${this.url}/${operation}`, {
22
22
  method: "POST",
23
- headers: { "content-type": "application/json" },
23
+ // JSON as text/plain: browsers send it without asking the API first (a CORS preflight), which
24
+ // saves a round trip.
25
+ headers: { "content-type": "text/plain" },
24
26
  body: JSON.stringify(request),
25
27
  signal,
26
28
  });
@@ -4,4 +4,6 @@ import type { SearchBrowserClient, SearchRequest, SearchResponse } from "../inde
4
4
  export declare function searchKey(request: SearchRequest): string;
5
5
  /** A cached response, if there's a recent one. */
6
6
  export declare function peek<T>(client: SearchBrowserClient, request: SearchRequest): SearchResponse<T> | null;
7
+ /** The search's response: from memory, from the same search in flight, or a new request. A caller's
8
+ * signal stops its own wait; the request stops once every caller waiting for it has. */
7
9
  export declare function cachedSearch<T>(client: SearchBrowserClient, request: SearchRequest, signal?: AbortSignal): Promise<SearchResponse<T>>;
@@ -2,6 +2,7 @@ import { canonical } from "./state.js";
2
2
  const size = 50;
3
3
  const maxAge = 5 * 60_000;
4
4
  const caches = new WeakMap();
5
+ const flights = new WeakMap();
5
6
  /** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
6
7
  * the query's first eight lowercase words, defaults left out, and list order ignored. */
7
8
  export function searchKey(request) {
@@ -24,19 +25,56 @@ export function peek(client, request) {
24
25
  const entry = caches.get(client)?.get(searchKey(request));
25
26
  return entry && Date.now() - entry.at < maxAge ? entry.response : null;
26
27
  }
27
- export async function cachedSearch(client, request, signal) {
28
+ /** The search's response: from memory, from the same search in flight, or a new request. A caller's
29
+ * signal stops its own wait; the request stops once every caller waiting for it has. */
30
+ export function cachedSearch(client, request, signal) {
28
31
  const found = peek(client, request);
29
32
  if (found)
30
- return found;
31
- const response = await client.search(request, { signal });
33
+ return Promise.resolve(found);
34
+ if (signal?.aborted)
35
+ return Promise.reject(signal.reason);
36
+ const key = searchKey(request);
37
+ let inFlight = flights.get(client);
38
+ if (!inFlight)
39
+ flights.set(client, (inFlight = new Map()));
40
+ let flight = inFlight.get(key);
41
+ if (!flight) {
42
+ const controller = new AbortController();
43
+ const started = {
44
+ controller,
45
+ waiting: 0,
46
+ response: client.search(request, { signal: controller.signal }).then((response) => {
47
+ remember(client, key, response);
48
+ return response;
49
+ }).finally(() => { if (inFlight.get(key) === started)
50
+ inFlight.delete(key); }),
51
+ };
52
+ inFlight.set(key, (flight = started));
53
+ }
54
+ const shared = flight;
55
+ shared.waiting++;
56
+ return new Promise((resolve, reject) => {
57
+ const leave = () => {
58
+ reject(signal.reason);
59
+ if (--shared.waiting > 0)
60
+ return;
61
+ // A later caller starts a new request rather than joining a stopped one.
62
+ if (inFlight.get(key) === shared)
63
+ inFlight.delete(key);
64
+ shared.controller.abort();
65
+ };
66
+ signal?.addEventListener("abort", leave, { once: true });
67
+ shared.response.then((response) => resolve(response), reject)
68
+ .finally(() => signal?.removeEventListener("abort", leave));
69
+ });
70
+ }
71
+ function remember(client, key, response) {
32
72
  let cache = caches.get(client);
33
73
  if (!cache)
34
74
  caches.set(client, (cache = new Map()));
35
- const key = searchKey(request);
36
75
  cache.delete(key);
37
76
  cache.set(key, { at: Date.now(), response });
38
77
  // A Map keeps insertion order, so the first key is the oldest.
39
78
  if (cache.size > size)
40
79
  cache.delete(cache.keys().next().value);
41
- return response;
42
80
  }
@@ -1,10 +1,10 @@
1
1
  export { FindlaneProvider, useClient, type ClientOptions } from "./client.js";
2
2
  export { useSearch, type UseSearchOptions, type UseSearchResult } from "./use-search.js";
3
3
  export { useAutocomplete, type AutocompleteOptions } from "./use-autocomplete.js";
4
- export { SearchProvider, useCurrentRefinements, useDidYouMean, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
4
+ export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
5
5
  export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, type HitsProps, type RefinementListProps, type SearchBoxProps, } from "./components.js";
6
6
  export { SearchDialog, type SearchDialogProps } from "./dialog.js";
7
7
  export { Highlight, Snippet, excerpt, highlightParts, type HighlightProps } from "./highlight.js";
8
8
  export { messageFor } from "./messages.js";
9
- export { emptyState, stateFromUrl, urlFromState, type Hit, type SearchConfig, type SearchState, type UrlParams, } from "./state.js";
9
+ export { emptyState, stateFromUrl, urlFromState, type Hit, type Near, type SearchConfig, type SearchState, type UrlParams, } from "./state.js";
10
10
  export type { ServerResults, StateChange } from "./store.js";
@@ -3,7 +3,7 @@
3
3
  export { FindlaneProvider, useClient } from "./client.js";
4
4
  export { useSearch } from "./use-search.js";
5
5
  export { useAutocomplete } from "./use-autocomplete.js";
6
- export { SearchProvider, useCurrentRefinements, useDidYouMean, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, } from "./search-provider.js";
6
+ export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, } from "./search-provider.js";
7
7
  export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, } from "./components.js";
8
8
  export { SearchDialog } from "./dialog.js";
9
9
  export { Highlight, Snippet, excerpt, highlightParts } from "./highlight.js";
@@ -1,8 +1,8 @@
1
1
  import { type ReactNode } from "react";
2
- import type { DidYouMean } from "../index.js";
2
+ import type { DidYouMean, GeoBox } from "../index.js";
3
3
  import { type ClientOptions } from "./client.js";
4
4
  import { type ServerResults, type StateChange } from "./store.js";
5
- import { type SearchConfig, type SearchState } from "./state.js";
5
+ import { type Near, type SearchConfig, type SearchState } from "./state.js";
6
6
  export type SearchProviderProps<T> = SearchConfig & ClientOptions & {
7
7
  children: ReactNode;
8
8
  /** Keeps the state in the page's URL (`?q=shoe&brand=Acme`), so results can be shared and Back
@@ -90,6 +90,20 @@ export declare function useToggle(field: string, value?: unknown): {
90
90
  set: (next: boolean) => void;
91
91
  toggle: () => void;
92
92
  };
93
+ /** Location search, with the index's location field: nearest first from a point or the visitor, and
94
+ * only what a map shows. `center` is where distances are from: the point asked for, or the visitor's
95
+ * location once known (null when it isn't). A map's moves (`setWithin`) replace the page's history
96
+ * entry rather than adding one, so Back doesn't step through every drag. */
97
+ export declare function useGeoSearch(): {
98
+ near: Near | undefined;
99
+ within: GeoBox | undefined;
100
+ center: import("../index.js").GeoPoint | null | undefined;
101
+ setNear: (near: Near | undefined) => void;
102
+ /** Near the visitor's approximate location, from their IP address; with radius (meters), only within it. */
103
+ nearVisitor: (radius?: number) => void;
104
+ setWithin: (within: GeoBox | undefined) => void;
105
+ clear: () => void;
106
+ };
93
107
  export declare function useSortBy(): {
94
108
  /** The chosen sort ID; undefined for the index's default. */
95
109
  sort: string | undefined;
@@ -169,6 +169,24 @@ export function useToggle(field, value = true) {
169
169
  const set = (next) => store.update(withFilter(field, next ? value : undefined));
170
170
  return { on, set, toggle: () => set(!on) };
171
171
  }
172
+ /** Location search, with the index's location field: nearest first from a point or the visitor, and
173
+ * only what a map shows. `center` is where distances are from: the point asked for, or the visitor's
174
+ * location once known (null when it isn't). A map's moves (`setWithin`) replace the page's history
175
+ * entry rather than adding one, so Back doesn't step through every drag. */
176
+ export function useGeoSearch() {
177
+ const [{ state, results }, store] = useSnapshot();
178
+ const setNear = (near) => store.update(({ near: _, ...current }) => ({ ...current, page: 0, ...(near ? { near } : {}) }));
179
+ return {
180
+ near: state.near,
181
+ within: state.within,
182
+ center: results?.response.near,
183
+ setNear,
184
+ /** Near the visitor's approximate location, from their IP address; with radius (meters), only within it. */
185
+ nearVisitor: (radius) => setNear({ visitor: true, ...(radius ? { radius } : {}) }),
186
+ setWithin: (within) => store.update(({ within: _, ...current }) => ({ ...current, page: 0, ...(within ? { within } : {}) }), false),
187
+ clear: () => store.update(({ near: _, within: __, ...current }) => ({ ...current, page: 0 })),
188
+ };
189
+ }
172
190
  export function useSortBy() {
173
191
  const [{ state }, store] = useSnapshot();
174
192
  return {
@@ -1,5 +1,7 @@
1
- import type { SearchRequest, SearchResponse } from "../index.js";
1
+ import type { GeoBox, SearchRequest, SearchResponse } from "../index.js";
2
2
  export type Hit<T> = SearchResponse<T>["hits"][number];
3
+ /** A point (or the visitor's location) to search near; see `near` in SearchRequest. */
4
+ export type Near = NonNullable<SearchRequest["near"]>;
3
5
  /** What a visitor has chosen on a results page. It round-trips through the URL. */
4
6
  export type SearchState = {
5
7
  q: string;
@@ -11,6 +13,10 @@ export type SearchState = {
11
13
  filters: Record<string, unknown>;
12
14
  /** The page, from 0. */
13
15
  page: number;
16
+ /** Nearest first from a point or the visitor (useGeoSearch). */
17
+ near?: Near;
18
+ /** Only records inside a box, such as a map's visible area (useGeoSearch). */
19
+ within?: GeoBox;
14
20
  };
15
21
  export type SearchConfig = {
16
22
  /** Fields to count values for. A facet's own selections don't narrow its counts, so its other
@@ -23,6 +29,9 @@ export type SearchConfig = {
23
29
  facetStats?: string[];
24
30
  /** Filters always applied, such as `{ published: true }`. They aren't shown as refinements. */
25
31
  filters?: Record<string, unknown>;
32
+ /** Where searches are near unless the state says otherwise, such as `{ visitor: true }` for the
33
+ * visitor's approximate location. Needs the index's location field. */
34
+ near?: Near;
26
35
  /** Hits per page: 1–50 (default 20). */
27
36
  pageSize?: number;
28
37
  /** The record fields to return (`attributesToRetrieve`); all of them when left out. */
@@ -16,6 +16,8 @@ export function planSearch(config, state) {
16
16
  return {
17
17
  q: state.q,
18
18
  ...(state.sort ? { sort: state.sort } : {}),
19
+ ...(state.near ?? config.near ? { near: state.near ?? config.near } : {}),
20
+ ...(state.within ? { within: state.within } : {}),
19
21
  ...(Object.keys(filters).length ? { filters } : {}),
20
22
  ...(facets.length ? { facets } : {}),
21
23
  ...(disjunctive.length ? { disjunctiveFacets: disjunctive } : {}),
@@ -62,6 +64,33 @@ function formatValue(value) {
62
64
  return value;
63
65
  return JSON.stringify(value);
64
66
  }
67
+ // Locations in the URL: near=51.5074,-0.1278 (with ,5000 for a radius in meters) or near=visitor (,5000),
68
+ // and within=north,east,south,west, the order CSS gives a box's sides.
69
+ const coordinate = (value) => String(Math.round(value * 1e6) / 1e6);
70
+ function parseNear(text) {
71
+ const parts = text?.split(",") ?? [];
72
+ const numbers = parts.map(Number);
73
+ const radius = (value) => value !== undefined && value > 0 ? { radius: value } : {};
74
+ if (parts[0] === "visitor" && parts.length <= 2 && (parts.length === 1 || numbers[1] > 0))
75
+ return { visitor: true, ...radius(numbers[1]) };
76
+ const [lat, lng] = numbers;
77
+ if (parts.length < 2 || parts.length > 3 || numbers.some((value) => !Number.isFinite(value)) ||
78
+ Math.abs(lat) > 90 || Math.abs(lng) > 180)
79
+ return undefined;
80
+ return { lat: lat, lng: lng, ...radius(numbers[2]) };
81
+ }
82
+ function parseWithin(text) {
83
+ const sides = text?.split(",").map(Number) ?? [];
84
+ if (sides.length !== 4 || sides.some((value) => !Number.isFinite(value)))
85
+ return undefined;
86
+ const [north, east, south, west] = sides;
87
+ return north > south && north <= 90 && south >= -90 && Math.abs(east) <= 180 && Math.abs(west) <= 180
88
+ ? { north, east, south, west } : undefined;
89
+ }
90
+ function formatNear(near) {
91
+ const at = "visitor" in near ? ["visitor"] : [coordinate(near.lat), coordinate(near.lng)];
92
+ return [...at, ...(near.radius ? [String(near.radius)] : [])].join(",");
93
+ }
65
94
  function paramsFrom(search) {
66
95
  if (typeof search === "string" || search instanceof URLSearchParams)
67
96
  return new URLSearchParams(search);
@@ -87,12 +116,16 @@ export function stateFromUrl(search, config) {
87
116
  filters[field] = parseValue(value);
88
117
  }
89
118
  const page = Number(params.get("page"));
119
+ const near = parseNear(params.get("near"));
120
+ const within = parseWithin(params.get("within"));
90
121
  return {
91
122
  q: params.get("q") ?? "",
92
123
  ...(params.get("sort") ? { sort: params.get("sort") } : {}),
93
124
  refinements,
94
125
  filters,
95
126
  page: Number.isInteger(page) && page > 1 ? page - 1 : 0,
127
+ ...(near ? { near } : {}),
128
+ ...(within ? { within } : {}),
96
129
  };
97
130
  }
98
131
  /** The query string for a state, followed by the URL's other parameters. */
@@ -111,7 +144,11 @@ export function urlFromState(state, config, current = "") {
111
144
  params.set(field, formatValue(value));
112
145
  if (state.page > 0)
113
146
  params.set("page", String(state.page + 1));
114
- const own = new Set(["q", "sort", "page", ...(config.facets ?? []), ...(config.filterFields ?? [])]);
147
+ if (state.near)
148
+ params.set("near", formatNear(state.near));
149
+ if (state.within)
150
+ params.set("within", [state.within.north, state.within.east, state.within.south, state.within.west].map(coordinate).join(","));
151
+ const own = new Set(["q", "sort", "page", "near", "within", ...(config.facets ?? []), ...(config.filterFields ?? [])]);
115
152
  for (const [key, value] of new URLSearchParams(current))
116
153
  if (!own.has(key))
117
154
  params.append(key, value);
@@ -47,8 +47,9 @@ export declare class SearchStore<T = unknown> {
47
47
  subscribe: (listener: () => void) => () => void;
48
48
  getSnapshot: () => Snapshot<T>;
49
49
  private set;
50
- /** A visitor's change. Changes to the query wait for a pause in typing; the others search at once. */
51
- update(change: (state: SearchState) => SearchState): void;
50
+ /** A visitor's change. Changes to the query wait for a pause in typing; the others search at once.
51
+ * `push` overrides how it reaches the history, as a map's moves replace the entry. */
52
+ update(change: (state: SearchState) => SearchState, push?: boolean): void;
52
53
  /** A state from outside, such as the URL after Back. */
53
54
  adopt(state: SearchState): void;
54
55
  /** A new client or config, from changed props. */
@@ -30,15 +30,16 @@ export class SearchStore {
30
30
  for (const listener of this.listeners)
31
31
  listener();
32
32
  }
33
- /** A visitor's change. Changes to the query wait for a pause in typing; the others search at once. */
34
- update(change) {
33
+ /** A visitor's change. Changes to the query wait for a pause in typing; the others search at once.
34
+ * `push` overrides how it reaches the history, as a map's moves replace the entry. */
35
+ update(change, push) {
35
36
  const previous = this.snapshot.state;
36
37
  const state = change(previous);
37
38
  if (canonical(state) === canonical(previous))
38
39
  return;
39
40
  const typing = state.q !== previous.q && canonical({ ...state, q: "", page: 0 }) === canonical({ ...previous, q: "", page: 0 });
40
41
  this.set({ state });
41
- this.onChange?.(state, { push: !typing });
42
+ this.onChange?.(state, { push: push ?? !typing });
42
43
  this.search(typing);
43
44
  }
44
45
  /** A state from outside, such as the URL after Back. */
@@ -36,6 +36,7 @@ export declare function useAutocomplete<T = Record<string, unknown>>(options?: A
36
36
  id: string;
37
37
  score: number | null;
38
38
  record: T;
39
+ distance?: number;
39
40
  highlights?: Record<string, string | string[]>;
40
41
  }[];
41
42
  total: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/browser",
3
- "version": "0.4.2",
3
+ "version": "0.6.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",