@priemskiyyy/scout-http
v0.1.0
Published
HTTP adapter for Scout: search your own backend through your own client, with the portable search as the request and a decoded wire format, refusals included, as the answer.
Maintainers
Readme
@priemskiyyy/scout-http
Search your own backend with Scout, through your own client. You give the adapter one function that sends a search, and your client keeps everything it already owns: the address, authentication, headers, retries and status handling.
Installation
pnpm add @priemskiyyy/scout@next @priemskiyyy/scout-http@nextCreate a Scout
import { index, Scout } from "@priemskiyyy/scout";
import { http } from "@priemskiyyy/scout-http";
const products = index({
schema: ProductSchema,
id: (product) => product.id,
searchable: ["title", "brand"],
filterable: ["brand", "price"],
facetable: { brand: "string" },
highlightable: ["title"],
sorts: { cheapest: { price: "asc" } },
});
const scout = new Scout({
indexes: { products },
adapters: {
products: http({
fingerprint: "catalogue-api",
request: async ({ search, signal }) => {
const response = await fetch("https://api.example.com/search", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${session.token}`,
},
body: JSON.stringify(search),
signal,
});
if (!response.ok) {
throw new Error(`The search endpoint answered ${response.status}.`);
}
return response.json();
},
}),
},
});The request
request({ search, signal }) sends one search and answers for it:
| request does | Scout makes of it |
| ------------------------------------------------------------ | -------------------------------------------------------------- |
| resolves with a ScoutWireResponse, the wire format below | the search's result |
| resolves with { "refusal": { "message": "..." } } | a ScoutError with code unsupported-feature and the message |
| throws or rejects | a ScoutError with code provider, with your error as cause |
| throws a ScoutError | that error, unchanged |
| is still running when the caller aborts or Scout is disposed | aborted or disposed at once: signal aborts |
searchis Scout's portable search, already resolved against the index:index,query,searchable,profile,sortas{ name, by }ornull,filteras the normalised filter AST ornull,facets,highlights,pageandprovider.JSON.stringify(search)is its wire form, and its type isAdapterSearchRequestfrom@priemskiyyy/scout.- An empty
querymeans browse: match everything the filter allows. - Pass
signalon, so an abandoned search stops on the wire. requestresolves withScoutWireResponse | ScoutWireRefusal, and the adapter reads it as typed. Your client owns that shape, the way it owns the address and authentication. Documents are still checked by the index's schema.
A backend you do not trust
When the answer's shape is not yours to vouch for, check it inside request with your schema library before Scout reads it:
import { z } from "zod";
const Answer = z.union([
z.object({ refusal: z.object({ message: z.string() }) }),
z.object({ hits: z.array(z.looseObject({ document: z.unknown() })) }),
]);
http({
fingerprint: "catalogue-api",
request: async ({ search, signal }) => {
const response = await fetch("https://api.example.com/search", {
method: "POST",
body: JSON.stringify(search),
signal,
});
if (!response.ok) {
throw new Error(`The search endpoint answered ${response.status}.`);
}
return Answer.parse(await response.json());
},
});Extend Answer with the total, facets and took your backend answers. A body the schema rejects throws, and reaches the caller as a provider error with the schema's error as its cause.
The answer
The backend answers a ScoutWireResponse. Only hits is required:
{
"hits": [
{
"document": { "id": "1", "title": "Trail running shoe" },
"highlights": {
"title": [
{
"path": ["title"],
"parts": [
{ "text": "Trail running ", "matched": false },
{ "text": "shoe", "matched": true }
],
"truncated": false
}
]
},
"score": 1.6
}
],
"total": { "value": 42, "accuracy": "exact", "scope": "matches" },
"facets": {
"brand": {
"values": [{ "value": "Apex", "count": 12 }],
"exhaustive": true
}
},
"took": 7,
"retrievable": 1000
}totalsays how sure the backend is:exact,estimated,lower-boundorunknown. A total left out isunknown, never counted from the page.- A facet's values are as the backend stores them, and Scout decodes each to the type the index declared.
exhaustiveleft out is"unknown". - Highlights are Scout's own fragments, keyed by field: a
path, theparts, and whether the value wastruncated(true,falseor"unknown"). Highlights left out are none. tookis the backend's own time in milliseconds, andnullwhen left out.retrievableis how many hits paging can reach, when the backend stops paging before its total.- Anything else on a hit, such as a score, reaches the application on
hit.native.
Options
| Option | Default | Meaning |
| ------------- | -------- | --------------------------------------------------------------------------------------------- |
| fingerprint | required | Names the backend in every query key. Never a credential: it reaches cache keys and devtools. |
| request | required | Sends one search through your client, as described above. |
Provider options are yours to type: http<{ tenant: string }>({ ... }) makes .provider({ tenant: "eu" }) typed, and it reaches the backend as search.provider.
Behavior
- Platforms: anywhere your client runs, browsers, React Native, Node and edge runtimes alike. There is no SDK and nothing to initialize.
- The backend decides what it supports. What it cannot serve, it refuses with the refusal body, and nothing checks what it claims, so run the conformance suite from
@priemskiyyy/scout/testingagainst it. - A search is sent once: there is no retry, no queue and no cache in the adapter. TanStack Query or your client owns those.
nativeis therequestyou passed.
Tests
The tests run against an in-memory backend injected through request. It receives each search as JSON and really evaluates it, so the conformance suite runs with evaluates: true. No request is sent over a network from this repository.
MIT
