@headband/react 0.1.1 → 0.2.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.mjs CHANGED
@@ -1,5 +1,69 @@
1
1
  "use client";
2
2
 
3
+ // src/experiments.ts
4
+ function stableHash(input) {
5
+ let hash = 2166136261;
6
+ for (let i = 0; i < input.length; i += 1) {
7
+ hash ^= input.charCodeAt(i);
8
+ hash = Math.imul(hash, 16777619);
9
+ }
10
+ return hash >>> 0;
11
+ }
12
+ function bucket(input, max) {
13
+ if (max <= 0) return 0;
14
+ return stableHash(input) % max;
15
+ }
16
+ function assignmentSeed(config, subjectId, scope) {
17
+ return `${config.assignmentSalt || config.experimentKey}:${config.experimentKey}:${scope}:${subjectId}`;
18
+ }
19
+ function buildExperimentConfigPath(query = {}) {
20
+ const params = new URLSearchParams();
21
+ if (query.projectId) params.set("projectId", query.projectId);
22
+ if (query.indexUid) params.set("indexUid", query.indexUid);
23
+ if (query.experimentKey) params.set("experimentKey", query.experimentKey);
24
+ const search = params.toString();
25
+ return `/v1/experiments/config${search ? `?${search}` : ""}`;
26
+ }
27
+ function isRunnableExperiment(config) {
28
+ return config.status === "active" && !config.killSwitch && config.trafficPercent > 0 && config.variants.length > 0;
29
+ }
30
+ function chooseExperimentVariant(config, subjectId) {
31
+ if (!isRunnableExperiment(config)) return null;
32
+ const trafficBucket = bucket(assignmentSeed(config, subjectId, "traffic"), 1e4) / 100;
33
+ if (trafficBucket >= config.trafficPercent) return null;
34
+ const totalWeight = config.variants.reduce(
35
+ (sum, variant) => sum + Math.max(0, variant.weight),
36
+ 0
37
+ );
38
+ if (totalWeight <= 0) return null;
39
+ const variantBucket = bucket(assignmentSeed(config, subjectId, "variant"), totalWeight);
40
+ let cursor = 0;
41
+ for (const variant of config.variants) {
42
+ cursor += Math.max(0, variant.weight);
43
+ if (variantBucket < cursor) return variant;
44
+ }
45
+ return config.variants[config.variants.length - 1] ?? null;
46
+ }
47
+ function assignExperiment(config, subjectId, options = {}) {
48
+ const variant = chooseExperimentVariant(config, subjectId);
49
+ if (!variant) return null;
50
+ return {
51
+ experimentKey: config.experimentKey,
52
+ variant: variant.key,
53
+ indexUid: variant.indexUid,
54
+ sessionId: options.sessionId ?? subjectId,
55
+ config
56
+ };
57
+ }
58
+ function experimentSearchMetadata(assignment) {
59
+ if (!assignment) return void 0;
60
+ return {
61
+ experimentKey: assignment.experimentKey,
62
+ variant: assignment.variant,
63
+ ...assignment.sessionId ? { sessionId: assignment.sessionId } : {}
64
+ };
65
+ }
66
+
3
67
  // src/client.ts
4
68
  var HeadbandError = class extends Error {
5
69
  constructor(message, status, code) {
@@ -11,22 +75,20 @@ var HeadbandError = class extends Error {
11
75
  };
12
76
  function headband(host, apiKey) {
13
77
  const baseUrl = host.replace(/\/+$/, "");
14
- async function request(path, body) {
78
+ async function request(path, options = {}) {
79
+ const method = options.method ?? "POST";
15
80
  let response;
16
81
  try {
17
82
  response = await fetch(`${baseUrl}${path}`, {
18
- method: "POST",
83
+ method,
19
84
  headers: {
20
- "Content-Type": "application/json",
85
+ ...method === "POST" ? { "Content-Type": "application/json" } : {},
21
86
  Authorization: `Bearer ${apiKey}`
22
87
  },
23
- body: JSON.stringify(body)
88
+ ...method === "POST" ? { body: JSON.stringify(options.body ?? {}) } : {}
24
89
  });
25
90
  } catch (err) {
26
- throw new HeadbandError(
27
- err instanceof Error ? err.message : "Network request failed",
28
- 0
29
- );
91
+ throw new HeadbandError(err instanceof Error ? err.message : "Network request failed", 0);
30
92
  }
31
93
  if (!response.ok) {
32
94
  let errorBody = {};
@@ -45,25 +107,110 @@ function headband(host, apiKey) {
45
107
  return {
46
108
  search(index, query, params) {
47
109
  return request("/v1/search", {
48
- index,
49
- q: query,
50
- ...params
110
+ body: {
111
+ index,
112
+ q: query,
113
+ ...params
114
+ }
51
115
  });
52
116
  },
53
117
  multiSearch(queries) {
54
- return request("/v1/multi-search", { queries });
118
+ return request("/v1/multi-search", { body: { queries } });
119
+ },
120
+ async getExperimentConfigs(query) {
121
+ const response = await request(
122
+ buildExperimentConfigPath(query),
123
+ { method: "GET" }
124
+ );
125
+ return { experiments: response.data?.experiments ?? [] };
126
+ },
127
+ async assignExperiment(query) {
128
+ const response = await request(
129
+ "/v1/experiments/assign",
130
+ { body: query }
131
+ );
132
+ if (!response.data) {
133
+ throw new HeadbandError("Experiment assignment response missing data", 500);
134
+ }
135
+ return response.data;
136
+ },
137
+ async recordExperimentEvent(event) {
138
+ const response = await request("/v1/experiments/events", {
139
+ body: event
140
+ });
141
+ if (!response.data) {
142
+ throw new HeadbandError("Experiment event response missing data", 500);
143
+ }
144
+ return response.data;
145
+ },
146
+ warm(target, opts) {
147
+ return request("/v1/warm", {
148
+ body: { ...target, ...opts?.level ? { level: opts.level } : {} }
149
+ });
55
150
  }
56
151
  };
57
152
  }
58
153
 
154
+ // src/filters.ts
155
+ function quoteFilterValue(value) {
156
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
157
+ return JSON.stringify(value);
158
+ }
159
+ function filterEq(attribute, value) {
160
+ return `${attribute} = ${quoteFilterValue(value)}`;
161
+ }
162
+ function filterNotEq(attribute, value) {
163
+ return `${attribute} != ${quoteFilterValue(value)}`;
164
+ }
165
+ function filterIn(attribute, values) {
166
+ return `${attribute} IN [${values.map(quoteFilterValue).join(", ")}]`;
167
+ }
168
+ function filterExists(attribute) {
169
+ return `${attribute} EXISTS`;
170
+ }
171
+ function filterRange(attribute, range) {
172
+ const parts = [];
173
+ const gte = range.gte ?? range.min;
174
+ const lte = range.lte ?? range.max;
175
+ if (range.gt !== void 0) parts.push(`${attribute} > ${range.gt}`);
176
+ if (gte !== void 0) parts.push(`${attribute} >= ${gte}`);
177
+ if (range.lt !== void 0) parts.push(`${attribute} < ${range.lt}`);
178
+ if (lte !== void 0) parts.push(`${attribute} <= ${lte}`);
179
+ return parts.length ? andFilters(...parts) : void 0;
180
+ }
181
+ function normalizeFilter(input) {
182
+ if (!input) return [];
183
+ return (Array.isArray(input) ? input : [input]).filter((part) => Boolean(part));
184
+ }
185
+ function andFilters(...filters) {
186
+ const parts = filters.flatMap(normalizeFilter);
187
+ if (parts.length === 0) return void 0;
188
+ if (parts.length === 1) return parts[0];
189
+ return parts.map((part) => `(${part})`).join(" AND ");
190
+ }
191
+ function orFilters(...filters) {
192
+ const parts = filters.flatMap(normalizeFilter);
193
+ if (parts.length === 0) return void 0;
194
+ if (parts.length === 1) return parts[0];
195
+ return parts.map((part) => `(${part})`).join(" OR ");
196
+ }
197
+ function combineFilters(baseFilter, generatedFilter) {
198
+ const base = normalizeFilter(baseFilter);
199
+ const generated = normalizeFilter(generatedFilter);
200
+ const combined = [...base, ...generated];
201
+ if (combined.length === 0) return void 0;
202
+ if (combined.length === 1) return combined[0];
203
+ return combined;
204
+ }
205
+
59
206
  // src/provider.tsx
60
207
  import {
61
208
  createContext,
62
209
  useContext,
63
210
  useCallback,
64
211
  useEffect,
65
- useRef,
66
212
  useMemo,
213
+ useRef,
67
214
  useState
68
215
  } from "react";
69
216
  import { jsx } from "react/jsx-runtime";
@@ -71,9 +218,7 @@ var HeadbandContext = createContext(null);
71
218
  function useHeadbandContext() {
72
219
  const ctx = useContext(HeadbandContext);
73
220
  if (!ctx) {
74
- throw new Error(
75
- "useHeadbandContext must be used within a <HeadbandProvider>."
76
- );
221
+ throw new Error("useHeadbandContext must be used within a <HeadbandProvider>.");
77
222
  }
78
223
  return ctx;
79
224
  }
@@ -83,18 +228,19 @@ function buildFilterString(refinements, ranges) {
83
228
  if (values.size === 0) return;
84
229
  if (values.size === 1) {
85
230
  const [v] = values;
86
- parts.push(`${attribute} = "${v}"`);
231
+ parts.push(filterEq(attribute, v));
87
232
  } else {
88
- const list = Array.from(values).map((v) => `"${v}"`).join(", ");
89
- parts.push(`${attribute} IN [${list}]`);
233
+ parts.push(filterIn(attribute, Array.from(values)));
90
234
  }
91
235
  });
92
236
  ranges.forEach((range, attribute) => {
93
237
  if (range.min !== void 0) {
94
- parts.push(`${attribute} >= ${range.min}`);
238
+ const generated = filterRange(attribute, { min: range.min });
239
+ if (generated) parts.push(generated);
95
240
  }
96
241
  if (range.max !== void 0) {
97
- parts.push(`${attribute} <= ${range.max}`);
242
+ const generated = filterRange(attribute, { max: range.max });
243
+ if (generated) parts.push(generated);
98
244
  }
99
245
  });
100
246
  return parts.length > 0 ? parts.join(" AND ") : void 0;
@@ -103,96 +249,92 @@ function HeadbandProvider({
103
249
  client,
104
250
  index,
105
251
  initialQuery = "",
252
+ experiment = null,
253
+ baseFilter,
254
+ mode,
106
255
  children
107
256
  }) {
108
257
  const [query, setQueryRaw] = useState(initialQuery);
109
258
  const [results, setResults] = useState(null);
110
259
  const [isLoading, setIsLoading] = useState(false);
111
260
  const [error, setError] = useState(null);
112
- const [refinements, setRefinements] = useState(
113
- () => /* @__PURE__ */ new Map()
114
- );
261
+ const [refinements, setRefinements] = useState(() => /* @__PURE__ */ new Map());
115
262
  const [ranges, setRanges] = useState(() => /* @__PURE__ */ new Map());
116
263
  const [sortBy, setSortBy] = useState(null);
117
264
  const [page, setPage] = useState(0);
118
265
  const [hitsPerPage, setHitsPerPage] = useState(20);
119
266
  const [facets, setFacets] = useState(() => /* @__PURE__ */ new Set());
120
- const timerRef = useRef(null);
121
267
  const requestIdRef = useRef(0);
122
- const refinementsRef = useRef(refinements);
123
- refinementsRef.current = refinements;
124
- const rangesRef = useRef(ranges);
125
- rangesRef.current = ranges;
126
- const facetsRef = useRef(facets);
127
- facetsRef.current = facets;
268
+ const generatedFilter = useMemo(
269
+ () => buildFilterString(refinements, ranges),
270
+ [refinements, ranges]
271
+ );
272
+ const filter = useMemo(
273
+ () => combineFilters(baseFilter, generatedFilter),
274
+ [baseFilter, generatedFilter]
275
+ );
276
+ const facetList = useMemo(() => facets.size > 0 ? Array.from(facets) : void 0, [facets]);
277
+ const sortList = useMemo(() => sortBy ? [sortBy] : void 0, [sortBy]);
128
278
  const executeSearch = useCallback(() => {
129
- const id = ++requestIdRef.current;
279
+ const requestId = ++requestIdRef.current;
130
280
  setIsLoading(true);
131
281
  setError(null);
132
- const filter = buildFilterString(refinementsRef.current, rangesRef.current);
133
- const facetList = facetsRef.current.size > 0 ? Array.from(facetsRef.current) : void 0;
134
- const sortList = sortBy ? [sortBy] : void 0;
135
282
  const params = {
136
283
  limit: hitsPerPage,
137
284
  offset: page * hitsPerPage,
138
285
  ...filter ? { filter } : {},
139
286
  ...facetList ? { facets: facetList } : {},
140
287
  ...sortList ? { sort: sortList } : {},
288
+ ...experiment ? experiment : {},
289
+ ...mode ? { mode } : {},
141
290
  attributesToHighlight: ["*"]
142
291
  };
143
292
  client.search(index, query, params).then((res) => {
144
- if (id === requestIdRef.current) {
145
- setResults(res);
146
- setIsLoading(false);
293
+ if (requestId !== requestIdRef.current) {
294
+ return;
147
295
  }
296
+ setResults(res);
297
+ setIsLoading(false);
148
298
  }).catch((err) => {
149
- if (id === requestIdRef.current) {
150
- setError(err instanceof Error ? err : new Error(String(err)));
151
- setIsLoading(false);
299
+ if (requestId !== requestIdRef.current) {
300
+ return;
152
301
  }
302
+ setError(err instanceof Error ? err : new Error(String(err)));
303
+ setIsLoading(false);
153
304
  });
154
- }, [client, index, query, sortBy, page, hitsPerPage]);
305
+ }, [client, index, query, filter, facetList, sortList, experiment, mode, page, hitsPerPage]);
155
306
  useEffect(() => {
156
- if (timerRef.current) {
157
- clearTimeout(timerRef.current);
158
- }
159
- timerRef.current = setTimeout(executeSearch, 200);
307
+ const timer = setTimeout(executeSearch, 200);
160
308
  return () => {
161
- if (timerRef.current) clearTimeout(timerRef.current);
309
+ clearTimeout(timer);
162
310
  };
163
- }, [executeSearch, refinements, ranges, facets]);
311
+ }, [executeSearch]);
164
312
  const setQuery = useCallback((q) => {
165
313
  setQueryRaw(q);
166
314
  setPage(0);
167
315
  }, []);
168
- const toggleRefinement = useCallback(
169
- (attribute, value2) => {
170
- setRefinements((prev) => {
171
- const next = new Map(prev);
172
- const current = new Set(next.get(attribute) ?? []);
173
- if (current.has(value2)) {
174
- current.delete(value2);
175
- } else {
176
- current.add(value2);
177
- }
178
- next.set(attribute, current);
179
- return next;
180
- });
181
- setPage(0);
182
- },
183
- []
184
- );
185
- const setRange = useCallback(
186
- (attribute, range) => {
187
- setRanges((prev) => {
188
- const next = new Map(prev);
189
- next.set(attribute, range);
190
- return next;
191
- });
192
- setPage(0);
193
- },
194
- []
195
- );
316
+ const toggleRefinement = useCallback((attribute, value2) => {
317
+ setRefinements((prev) => {
318
+ const next = new Map(prev);
319
+ const current = new Set(next.get(attribute) ?? []);
320
+ if (current.has(value2)) {
321
+ current.delete(value2);
322
+ } else {
323
+ current.add(value2);
324
+ }
325
+ next.set(attribute, current);
326
+ return next;
327
+ });
328
+ setPage(0);
329
+ }, []);
330
+ const setRange = useCallback((attribute, range) => {
331
+ setRanges((prev) => {
332
+ const next = new Map(prev);
333
+ next.set(attribute, range);
334
+ return next;
335
+ });
336
+ setPage(0);
337
+ }, []);
196
338
  const clearRange = useCallback((attribute) => {
197
339
  setRanges((prev) => {
198
340
  const next = new Map(prev);
@@ -248,7 +390,18 @@ function HeadbandProvider({
248
390
  unregisterFacet,
249
391
  refresh
250
392
  }),
251
- [setQuery, toggleRefinement, setRange, clearRange, registerFacet, unregisterFacet, refresh]
393
+ [
394
+ setQuery,
395
+ setPage,
396
+ setSortBy,
397
+ setHitsPerPage,
398
+ toggleRefinement,
399
+ setRange,
400
+ clearRange,
401
+ registerFacet,
402
+ unregisterFacet,
403
+ refresh
404
+ ]
252
405
  );
253
406
  const value = useMemo(
254
407
  () => ({ client, index, state, actions }),
@@ -310,18 +463,19 @@ function useRefinementList({
310
463
  actions.unregisterFacet(attribute);
311
464
  };
312
465
  }, [attribute, actions]);
313
- const refined = state.refinements.get(attribute) ?? /* @__PURE__ */ new Set();
466
+ const refined = state.refinements.get(attribute);
314
467
  const items = useMemo3(() => {
468
+ const activeRefinements = refined ?? /* @__PURE__ */ new Set();
315
469
  const distribution = state.results?.facetDistribution?.[attribute] ?? {};
316
470
  const allValues = /* @__PURE__ */ new Set([
317
471
  ...Object.keys(distribution),
318
- ...refined
472
+ ...activeRefinements
319
473
  ]);
320
474
  let result = Array.from(allValues).map(
321
475
  (value) => ({
322
476
  value,
323
477
  count: distribution[value] ?? 0,
324
- isRefined: refined.has(value)
478
+ isRefined: activeRefinements.has(value)
325
479
  })
326
480
  );
327
481
  if (sortOrder === "alpha") {
@@ -624,7 +778,11 @@ function SearchBox({
624
778
  // src/components/Hits.tsx
625
779
  import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
626
780
  function DefaultHit({ hit }) {
627
- const { _formatted, __data, ...rest } = hit;
781
+ const rest = Object.fromEntries(
782
+ Object.entries(hit).filter(
783
+ ([key]) => key !== "_formatted" && key !== "__data"
784
+ )
785
+ );
628
786
  return /* @__PURE__ */ jsx3("div", { className: "rounded-lg border border-gray-200 bg-white p-4 text-sm dark:border-gray-700 dark:bg-gray-900", children: /* @__PURE__ */ jsx3("pre", { className: "overflow-x-auto whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300", children: JSON.stringify(rest, null, 2) }) });
629
787
  }
630
788
  function DefaultEmpty() {
@@ -685,7 +843,7 @@ function RefinementList({
685
843
  sortBy
686
844
  });
687
845
  const handleChange = useCallback8(
688
- (value) => (_e) => {
846
+ (value) => () => {
689
847
  refine(value);
690
848
  },
691
849
  [refine]
@@ -914,11 +1072,7 @@ function Highlight({
914
1072
  }
915
1073
 
916
1074
  // src/components/RangeInput.tsx
917
- import {
918
- useState as useState2,
919
- useCallback as useCallback11,
920
- useEffect as useEffect3
921
- } from "react";
1075
+ import { useCallback as useCallback11 } from "react";
922
1076
  import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
923
1077
  function RangeInput({
924
1078
  attribute,
@@ -929,17 +1083,15 @@ function RangeInput({
929
1083
  maxPlaceholder = "Max"
930
1084
  }) {
931
1085
  const { min, max, stats, setRange, clear } = useRange({ attribute });
932
- const [localMin, setLocalMin] = useState2(min !== void 0 ? String(min) : "");
933
- const [localMax, setLocalMax] = useState2(max !== void 0 ? String(max) : "");
934
- useEffect3(() => {
935
- setLocalMin(min !== void 0 ? String(min) : "");
936
- }, [min]);
937
- useEffect3(() => {
938
- setLocalMax(max !== void 0 ? String(max) : "");
939
- }, [max]);
1086
+ const defaultMin = min !== void 0 ? String(min) : "";
1087
+ const defaultMax = max !== void 0 ? String(max) : "";
1088
+ const formKey = `${attribute}:${defaultMin}:${defaultMax}`;
940
1089
  const handleSubmit = useCallback11(
941
1090
  (e) => {
942
1091
  e.preventDefault();
1092
+ const formData = new FormData(e.currentTarget);
1093
+ const localMin = String(formData.get("min") ?? "");
1094
+ const localMax = String(formData.get("max") ?? "");
943
1095
  const parsedMin = localMin !== "" ? Number(localMin) : void 0;
944
1096
  const parsedMax = localMax !== "" ? Number(localMax) : void 0;
945
1097
  if (parsedMin === void 0 && parsedMax === void 0) {
@@ -951,15 +1103,7 @@ function RangeInput({
951
1103
  });
952
1104
  }
953
1105
  },
954
- [localMin, localMax, setRange, clear]
955
- );
956
- const handleMinChange = useCallback11(
957
- (e) => setLocalMin(e.target.value),
958
- []
959
- );
960
- const handleMaxChange = useCallback11(
961
- (e) => setLocalMax(e.target.value),
962
- []
1106
+ [setRange, clear]
963
1107
  );
964
1108
  const containerClass = styled ? `space-y-2 ${className ?? ""}`.trim() : className ?? "";
965
1109
  const inputClass = styled ? "w-full rounded border border-gray-200 bg-white px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500" : "";
@@ -994,9 +1138,9 @@ function RangeInput({
994
1138
  /* @__PURE__ */ jsx9(
995
1139
  "input",
996
1140
  {
1141
+ name: "min",
997
1142
  type: "number",
998
- value: localMin,
999
- onChange: handleMinChange,
1143
+ defaultValue: defaultMin,
1000
1144
  placeholder: minPlaceholder,
1001
1145
  "aria-label": `Minimum ${attribute}`,
1002
1146
  className: inputClass
@@ -1013,9 +1157,9 @@ function RangeInput({
1013
1157
  /* @__PURE__ */ jsx9(
1014
1158
  "input",
1015
1159
  {
1160
+ name: "max",
1016
1161
  type: "number",
1017
- value: localMax,
1018
- onChange: handleMaxChange,
1162
+ defaultValue: defaultMax,
1019
1163
  placeholder: maxPlaceholder,
1020
1164
  "aria-label": `Maximum ${attribute}`,
1021
1165
  className: inputClass
@@ -1024,7 +1168,8 @@ function RangeInput({
1024
1168
  /* @__PURE__ */ jsx9("button", { type: "submit", className: buttonClass, children: "Go" })
1025
1169
  ] })
1026
1170
  ]
1027
- }
1171
+ },
1172
+ formKey
1028
1173
  );
1029
1174
  }
1030
1175
 
@@ -1084,7 +1229,21 @@ export {
1084
1229
  SearchBox,
1085
1230
  SortBy,
1086
1231
  Stats,
1232
+ andFilters,
1233
+ assignExperiment,
1234
+ buildExperimentConfigPath,
1235
+ chooseExperimentVariant,
1236
+ combineFilters,
1237
+ experimentSearchMetadata,
1238
+ filterEq,
1239
+ filterExists,
1240
+ filterIn,
1241
+ filterNotEq,
1242
+ filterRange,
1087
1243
  headband,
1244
+ isRunnableExperiment,
1245
+ orFilters,
1246
+ quoteFilterValue,
1088
1247
  useHeadbandContext,
1089
1248
  useHighlight,
1090
1249
  useHits,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@headband/react",
3
- "version": "0.1.1",
4
- "description": "React hooks and components for Headband search — an InstantSearch-compatible SDK",
3
+ "version": "0.2.0",
4
+ "description": "React hooks and components for Headband search — an InstantSearch-inspired SDK",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",