@headband/react 0.1.0 → 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,90 +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);
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]);
122
278
  const executeSearch = useCallback(() => {
123
- const id = ++requestIdRef.current;
279
+ const requestId = ++requestIdRef.current;
124
280
  setIsLoading(true);
125
281
  setError(null);
126
- const filter = buildFilterString(refinements, ranges);
127
- const facetList = facets.size > 0 ? Array.from(facets) : void 0;
128
- const sortList = sortBy ? [sortBy] : void 0;
129
282
  const params = {
130
283
  limit: hitsPerPage,
131
284
  offset: page * hitsPerPage,
132
285
  ...filter ? { filter } : {},
133
286
  ...facetList ? { facets: facetList } : {},
134
287
  ...sortList ? { sort: sortList } : {},
288
+ ...experiment ? experiment : {},
289
+ ...mode ? { mode } : {},
135
290
  attributesToHighlight: ["*"]
136
291
  };
137
292
  client.search(index, query, params).then((res) => {
138
- if (id === requestIdRef.current) {
139
- setResults(res);
140
- setIsLoading(false);
293
+ if (requestId !== requestIdRef.current) {
294
+ return;
141
295
  }
296
+ setResults(res);
297
+ setIsLoading(false);
142
298
  }).catch((err) => {
143
- if (id === requestIdRef.current) {
144
- setError(err instanceof Error ? err : new Error(String(err)));
145
- setIsLoading(false);
299
+ if (requestId !== requestIdRef.current) {
300
+ return;
146
301
  }
302
+ setError(err instanceof Error ? err : new Error(String(err)));
303
+ setIsLoading(false);
147
304
  });
148
- }, [client, index, query, refinements, ranges, sortBy, page, hitsPerPage, facets]);
305
+ }, [client, index, query, filter, facetList, sortList, experiment, mode, page, hitsPerPage]);
149
306
  useEffect(() => {
150
- if (timerRef.current) {
151
- clearTimeout(timerRef.current);
152
- }
153
- timerRef.current = setTimeout(executeSearch, 200);
307
+ const timer = setTimeout(executeSearch, 200);
154
308
  return () => {
155
- if (timerRef.current) clearTimeout(timerRef.current);
309
+ clearTimeout(timer);
156
310
  };
157
311
  }, [executeSearch]);
158
312
  const setQuery = useCallback((q) => {
159
313
  setQueryRaw(q);
160
314
  setPage(0);
161
315
  }, []);
162
- const toggleRefinement = useCallback(
163
- (attribute, value2) => {
164
- setRefinements((prev) => {
165
- const next = new Map(prev);
166
- const current = new Set(next.get(attribute) ?? []);
167
- if (current.has(value2)) {
168
- current.delete(value2);
169
- } else {
170
- current.add(value2);
171
- }
172
- next.set(attribute, current);
173
- return next;
174
- });
175
- setPage(0);
176
- },
177
- []
178
- );
179
- const setRange = useCallback(
180
- (attribute, range) => {
181
- setRanges((prev) => {
182
- const next = new Map(prev);
183
- next.set(attribute, range);
184
- return next;
185
- });
186
- setPage(0);
187
- },
188
- []
189
- );
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
+ }, []);
190
338
  const clearRange = useCallback((attribute) => {
191
339
  setRanges((prev) => {
192
340
  const next = new Map(prev);
@@ -242,7 +390,18 @@ function HeadbandProvider({
242
390
  unregisterFacet,
243
391
  refresh
244
392
  }),
245
- [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
+ ]
246
405
  );
247
406
  const value = useMemo(
248
407
  () => ({ client, index, state, actions }),
@@ -304,18 +463,19 @@ function useRefinementList({
304
463
  actions.unregisterFacet(attribute);
305
464
  };
306
465
  }, [attribute, actions]);
307
- const refined = state.refinements.get(attribute) ?? /* @__PURE__ */ new Set();
466
+ const refined = state.refinements.get(attribute);
308
467
  const items = useMemo3(() => {
468
+ const activeRefinements = refined ?? /* @__PURE__ */ new Set();
309
469
  const distribution = state.results?.facetDistribution?.[attribute] ?? {};
310
470
  const allValues = /* @__PURE__ */ new Set([
311
471
  ...Object.keys(distribution),
312
- ...refined
472
+ ...activeRefinements
313
473
  ]);
314
474
  let result = Array.from(allValues).map(
315
475
  (value) => ({
316
476
  value,
317
477
  count: distribution[value] ?? 0,
318
- isRefined: refined.has(value)
478
+ isRefined: activeRefinements.has(value)
319
479
  })
320
480
  );
321
481
  if (sortOrder === "alpha") {
@@ -618,7 +778,11 @@ function SearchBox({
618
778
  // src/components/Hits.tsx
619
779
  import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
620
780
  function DefaultHit({ hit }) {
621
- const { _formatted, __data, ...rest } = hit;
781
+ const rest = Object.fromEntries(
782
+ Object.entries(hit).filter(
783
+ ([key]) => key !== "_formatted" && key !== "__data"
784
+ )
785
+ );
622
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) }) });
623
787
  }
624
788
  function DefaultEmpty() {
@@ -679,7 +843,7 @@ function RefinementList({
679
843
  sortBy
680
844
  });
681
845
  const handleChange = useCallback8(
682
- (value) => (_e) => {
846
+ (value) => () => {
683
847
  refine(value);
684
848
  },
685
849
  [refine]
@@ -908,11 +1072,7 @@ function Highlight({
908
1072
  }
909
1073
 
910
1074
  // src/components/RangeInput.tsx
911
- import {
912
- useState as useState2,
913
- useCallback as useCallback11,
914
- useEffect as useEffect3
915
- } from "react";
1075
+ import { useCallback as useCallback11 } from "react";
916
1076
  import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
917
1077
  function RangeInput({
918
1078
  attribute,
@@ -923,17 +1083,15 @@ function RangeInput({
923
1083
  maxPlaceholder = "Max"
924
1084
  }) {
925
1085
  const { min, max, stats, setRange, clear } = useRange({ attribute });
926
- const [localMin, setLocalMin] = useState2(min !== void 0 ? String(min) : "");
927
- const [localMax, setLocalMax] = useState2(max !== void 0 ? String(max) : "");
928
- useEffect3(() => {
929
- setLocalMin(min !== void 0 ? String(min) : "");
930
- }, [min]);
931
- useEffect3(() => {
932
- setLocalMax(max !== void 0 ? String(max) : "");
933
- }, [max]);
1086
+ const defaultMin = min !== void 0 ? String(min) : "";
1087
+ const defaultMax = max !== void 0 ? String(max) : "";
1088
+ const formKey = `${attribute}:${defaultMin}:${defaultMax}`;
934
1089
  const handleSubmit = useCallback11(
935
1090
  (e) => {
936
1091
  e.preventDefault();
1092
+ const formData = new FormData(e.currentTarget);
1093
+ const localMin = String(formData.get("min") ?? "");
1094
+ const localMax = String(formData.get("max") ?? "");
937
1095
  const parsedMin = localMin !== "" ? Number(localMin) : void 0;
938
1096
  const parsedMax = localMax !== "" ? Number(localMax) : void 0;
939
1097
  if (parsedMin === void 0 && parsedMax === void 0) {
@@ -945,15 +1103,7 @@ function RangeInput({
945
1103
  });
946
1104
  }
947
1105
  },
948
- [localMin, localMax, setRange, clear]
949
- );
950
- const handleMinChange = useCallback11(
951
- (e) => setLocalMin(e.target.value),
952
- []
953
- );
954
- const handleMaxChange = useCallback11(
955
- (e) => setLocalMax(e.target.value),
956
- []
1106
+ [setRange, clear]
957
1107
  );
958
1108
  const containerClass = styled ? `space-y-2 ${className ?? ""}`.trim() : className ?? "";
959
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" : "";
@@ -988,9 +1138,9 @@ function RangeInput({
988
1138
  /* @__PURE__ */ jsx9(
989
1139
  "input",
990
1140
  {
1141
+ name: "min",
991
1142
  type: "number",
992
- value: localMin,
993
- onChange: handleMinChange,
1143
+ defaultValue: defaultMin,
994
1144
  placeholder: minPlaceholder,
995
1145
  "aria-label": `Minimum ${attribute}`,
996
1146
  className: inputClass
@@ -1007,9 +1157,9 @@ function RangeInput({
1007
1157
  /* @__PURE__ */ jsx9(
1008
1158
  "input",
1009
1159
  {
1160
+ name: "max",
1010
1161
  type: "number",
1011
- value: localMax,
1012
- onChange: handleMaxChange,
1162
+ defaultValue: defaultMax,
1013
1163
  placeholder: maxPlaceholder,
1014
1164
  "aria-label": `Maximum ${attribute}`,
1015
1165
  className: inputClass
@@ -1018,7 +1168,8 @@ function RangeInput({
1018
1168
  /* @__PURE__ */ jsx9("button", { type: "submit", className: buttonClass, children: "Go" })
1019
1169
  ] })
1020
1170
  ]
1021
- }
1171
+ },
1172
+ formKey
1022
1173
  );
1023
1174
  }
1024
1175
 
@@ -1078,7 +1229,21 @@ export {
1078
1229
  SearchBox,
1079
1230
  SortBy,
1080
1231
  Stats,
1232
+ andFilters,
1233
+ assignExperiment,
1234
+ buildExperimentConfigPath,
1235
+ chooseExperimentVariant,
1236
+ combineFilters,
1237
+ experimentSearchMetadata,
1238
+ filterEq,
1239
+ filterExists,
1240
+ filterIn,
1241
+ filterNotEq,
1242
+ filterRange,
1081
1243
  headband,
1244
+ isRunnableExperiment,
1245
+ orFilters,
1246
+ quoteFilterValue,
1082
1247
  useHeadbandContext,
1083
1248
  useHighlight,
1084
1249
  useHits,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@headband/react",
3
- "version": "0.1.0",
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",