MiniSearch serves as a privacy-preserving search interface with optional AI augmentation. The system prioritizes user privacy by routing all web searches through SearXNG, which aggregates results from multiple search engines without tracking. AI processing can occur entirely client-side in the browser, ensuring no user queries or responses leave the device.
The architecture follows a layered design where search, AI inference, and presentation concerns are separated.
MiniSearch integrates multiple technology stacks within a unified deployment container:
- React - UI framework
- React DOM - DOM rendering
- Mantine UI - Component library (
@mantine/core,@mantine/hooks,@mantine/carousel) - Vite - Build tool with React plugin
- TypeScript - Type safety
- @wllama/wllama - Client-side AI inference (WebGPU-accelerated or CPU via WebAssembly)
- AI SDK - AI integration layer
- @ai-sdk/openai-compatible - Unified AI interface
- Dexie - IndexedDB management
- create-pubsub - State management (avoid React Context)
- usePubSub - Component subscriptions
The application has three primary entry points:
-
Browser Entry:
client/index.tsxinitializes the React application, mounting the root component and setting up error boundaries. -
Server Entry:
vite.config.tsconfigures the Vite development and preview servers, registering server hooks for search and inference endpoints. -
Container Entry:
Dockerfilestarts both SearXNG and the Node.js server in a single process via shell command composition.
The Docker container runs three services concurrently:
- SearXNG - Privacy-focused metasearch engine
- ONNX Runtime - In-process inference for result reranking
- Node.js application - Main application server
The build creates a runtime image with Node.js and Python environments. The container entrypoint starts SearXNG in the background and then launches the Node.js application.
MiniSearch uses a PubSub pattern for state management rather than React Context, enabling loose coupling between components and business logic modules:
PubSub channels are created using the create-pubsub package and provide type-safe publish/subscribe interfaces. Components subscribe via the usePubSub hook, and business logic modules publish state updates directly.
MiniSearch employs a dual-layer persistence approach:
- IndexedDB - Local storage for search history, settings, cached results, and saved AI transcripts
- TTL-based caching - 15-minute freshness cache for search results to minimize API calls, with a 24-hour stale retention as a fallback when the live search fails
Search history is backed by a Dexie database that keeps three coordinated tables (search runs, LLM responses, chat turns) along with automatic retention/max-entry cleanup. See docs/search-history.md for the complete schema and invariants. The caching layer minimizes redundant API calls to SearXNG while maintaining fresh results. Search results cached in IndexedDB have a 15-minute freshness TTL, after which new searches bypass the cache; an expired entry is kept for 24 hours so a failed live search can serve it, flagged stale, instead of failing. Cached image results store the thumbnail URLs rather than the bytes, so a thumbnail host whose URLs expire or are signed shorter than the retention window shows host-name tiles instead of images on a stale restore.
Long-running chat sessions use an in-memory conversation summary that rolls excess turns into a structured digest before continuing generation. Details about the token budgeting and summary refresh flow live in docs/conversation-memory.md.
The system supports two operational modes:
- Hot module replacement (HMR) on port 7861
- Volume mount for live code updates
- Vite dev server with source maps
- Pre-built static assets in /dist
- Vite preview server (no HMR)
- Optimized bundle with minification
Both modes run the same underlying services (SearXNG, the reranker) but differ in how the frontend is served and rebuilt.
The system executes two parallel flows when a user submits a query:
- User submits a query via SearchForm
- Client checks IndexedDB cache for matching query hash
- On cache miss: authenticated HTTP request to
/search/textor/search/images - Server verifies request token via
searchToken.ts(CSRF protection) webSearchService.tsforwards query to SearXNG athttp://127.0.0.1:8888- Raw results are deduplicated, cleaned, and optionally reranked
- Image results keep the thumbnail URLs SearXNG returned, and results that came back without one are dropped; the client loads each tile on its own from
/thumbnail, so the response does not wait on any thumbnail host - Results returned as structured JSON and cached in IndexedDB (15-minute freshness TTL)
textGeneration.tsorchestrates response generation after search completes- State machine transitions:
idle->loadingModel/preparingToGenerate->awaitingSearchResults->generating->completed/failed/interrupted(see Text Generation States below;loadingModelonly occurs on the browser/Wllama path, other backends usepreparingToGenerate) - Search results are formatted and injected into system prompt via
{{searchResults}}placeholder. WithenablePageContentFetchon, the pages behind the top results are read through/page-contentfirst and their excerpts are appended to each result (seedocs/page-content.md) - LLM generates response with streaming tokens
- Response updates throttled to ~12 updates/sec via
throttleitto prevent React render overload - Response saved to history database via
saveLlmResponseForQuery
The textGeneration module orchestrates the entire search-to-response flow, managing search requests, LLM context preparation, and response streaming. Search results are optionally reranked in-process via ONNX Runtime before being passed to the LLM for response generation.
server/webSearchService.ts implements resilience patterns for SearXNG integration:
- Circuit Breaker: Opens after 5 consecutive failures, blocking requests for 60 seconds before attempting reset
- Retry Logic: Exponential backoff for HTTP 500 errors, up to 3 retries
- Fail-Fast on Suspending Engines: An empty response in which every unresponsive engine is under a long suspension (CAPTCHA, rate limit, access denied; SearXNG suspends them for an hour to a day) throws on the first attempt instead of spending the retry budget, which it could not possibly outlast. Transient engine errors (timeouts, server API errors) keep the full retry budget.
- Content Processing: Converts HTML results to plain text, strips emojis for cleaner output
- Lazy Thumbnail Loading: The search response returns thumbnail URLs as SearXNG sent them; the client then loads each tile from
/thumbnail, which applies the SSRF guard and serves from an in-process LRU, so a dead thumbnail host delays one tile instead of the whole grid
None of this changes which engines answer. That is decided inside SearXNG, and which engines answer for the general category a text search asks for is decided by the pinned SearXNG build, so a thin result page is usually an engine problem rather than a resilience problem. docs/configuration.md covers replacing the SearXNG settings file when the container starts, which is how an operator widens the set or drops an engine that is blocked from their IP.
CSRF protection uses a token the server owns for its lifetime:
- Generation:
regenerateSearchToken()draws 32 random bytes on first use in a process and writes them to the token file; the file is a record, never read back as a source of truth - Storage: The token is held in memory for the life of the process, and recorded at
{os.tempdir()}/minisearch-tokenwith0600permissions - Distribution: Server serves the token to the client at runtime through
/api/config, so a client always holds the token of the server answering it - Client Hashing: Client hashes token before sending in requests (never sends raw token)
- Verification: Server compares request hash against the token it is holding
- Caching: Verified tokens stored in
server/verifiedTokens.ts(in-memoryMapof token to last-seen time) to avoid redundant cryptographic operations - Rejection Caching: Tokens that fail a completed verification are kept in a bounded set (
server/rejectedTokens.ts) until it evicts them at the cap, so a replay of a dead token is refused without a second argon2 verification; a verification that never produced a result, because the hash could not be parsed or the token file could not be read, leaves nothing behind
Rewriting the token file under a running server does not re-key it: the server keeps the token it is already handing out, and logs once that the file diverged if a request is rejected while it has.
MiniSearch uses a PubSub-based architecture where state flows through independent channels. Components subscribe only to the channels they need, minimizing unnecessary re-renders.
Text Generation States:
idle- No active generationawaitingModelDownloadAllowance- Waiting for user consent to download a browser modelloadingModel- Downloading or initializing the browser (Wllama) modelawaitingSearchResults- Waiting for search to complete before generatingpreparingToGenerate- Building the prompt/request just before calling the inference backend (OpenAI-compatible, Internal API, and AI Horde paths)generating- Streaming response tokensinterrupted- Generation was cancelled by the usercompleted- Full response receivedfailed- Error occurred
Search States:
idle- No active searchrunning- Search in progresscompleted- Results receivedfailed- Error occurred
- Client retrieves cached token hash from
lastSearchTokenHashPubSub(localStorage-backed) - If expired or missing, generates new hash from the
searchTokenin/api/config - Request includes hashed token as query parameter
- Server hook verifies token against stored value
- On success, token added to
verifiedTokensSet for subsequent requests
Streaming LLM output produces token-by-token state changes that would overwhelm React's rendering pipeline. Two channels apply throttling via throttleit:
| Channel | Throttle Interval | Purpose |
|---|---|---|
responsePubSub |
~83ms (12/sec) | AI response text streaming |
reasoningContentPubSub |
~83ms (12/sec) | Reasoning/thinking content streaming |
Callers write tokens directly to updateResponse or updateReasoningContent without awareness of internal throttling.
Three channels register built-in side-effect subscribers at module load time for automatic logging:
| Channel | Side Effect |
|---|---|
textGenerationStatePubSub |
Logs state transitions via addLogEntry |
textSearchStatePubSub |
Logs state transitions via addLogEntry |
imageSearchStatePubSub |
Logs state transitions via addLogEntry |
The build pipeline uses Biome for linting and formatting, TypeScript for type checking, and Vitest for testing. The Docker build compiles native dependencies from source in a builder stage, then copies only the necessary binaries to the final runtime image.
MiniSearch implements all server-side logic as Vite plugin hooks. Each hook registers middleware on Vite's HTTP server, working identically in both dev (vite) and production preview (vite preview) modes. Hooks are declared in vite.config.ts and registered via configureServer/configurePreviewServer callbacks.
| Hook | File | Purpose |
|---|---|---|
compressionServerHook |
server/compressionServerHook.ts |
gzip/brotli compression for all responses |
crossOriginServerHook |
server/crossOriginServerHook.ts |
COOP/COEP headers for SharedArrayBuffer |
searchEndpointServerHook |
server/searchEndpointServerHook.ts |
/search/text and /search/images endpoints proxied to SearXNG |
pageContentEndpointServerHook |
server/pageContentEndpointServerHook.ts |
/page-content endpoint that reads result pages for answer grounding |
statusEndpointServerHook |
server/statusEndpointServerHook.ts |
/status health check endpoint |
cacheServerHook |
server/cacheServerHook.ts |
Cache-Control headers (preview only) |
validateAccessKeyServerHook |
server/validateAccessKeyServerHook.ts |
Access key validation endpoint |
internalApiEndpointServerHook |
server/internalApiEndpointServerHook.ts |
/inference proxy to self-hosted API |
rerankerServiceHook |
server/rerankerServiceHook.ts |
Reranker model lifecycle management for result reranking |
Key server-side modules:
server/webSearchService.ts: Integrates with SearXNG athttp://127.0.0.1:8888. Implements a circuit breaker (opens after 5 failures, resets after 60s) and retry logic (up to 3 retries with exponential backoff, for 500s and for empty responses naming transiently unresponsive engines; an all-suspended set fails fast on the first attempt, since SearXNG suspensions last an hour to a day).server/pageContentService.ts: Reads result pages for answer grounding: SSRF-guarded fetches with a byte cap, readable-text extraction, and query-relevant passage selection. Reads run under a per-host circuit breaker (server/pageReadHostBreaker.ts) that skips a host for 5 minutes after 3 refusals in a row, then probes it with one read.server/searchToken.ts: Manages a token at{os.tempdir()}/minisearch-tokenused for CSRF protection on search requests.server/verifiedTokens.ts: In-memoryMapof verified session token to last-seen time, evicted after 30 idle minutes, plus a cumulative count of the distinct sessions seen since the last restart.server/rejectedTokens.ts: Bounded in-memory set of tokens that already failed a completed verification, so a replay is refused without a second argon2 check until the set evicts it at the cap; a token refused once cannot become valid in the same process, so the set is exact, and a token that never got a verification result never occupies a slot.server/searchesSinceLastRestart.ts: In-memory counters for search analytics.
The cacheServerHook sets Cache-Control headers on every response:
| Path Pattern | Cache-Control Header | Rationale |
|---|---|---|
/assets/* |
public, max-age=31536000, immutable |
Content-hashed filenames never change |
/ or *.html |
no-cache |
HTML must always check for updates |
| Everything else | public, max-age=86400, must-revalidate |
24-hour cache with revalidation |
The /status endpoint returns a JSON object:
| Field | Type | Description |
|---|---|---|
uptime |
string | Human-readable server uptime, measured from startedAt |
startedAt |
string | ISO timestamp of the server start; all the "since last restart" counters below are measured from this moment |
sessions |
number | Distinct verified sessions since last restart, which the two per-session averages below divide by |
activeSessions |
number | Sessions still in the cache, dropped after 30 idle minutes |
textualSearches |
number | Text search count since last restart |
graphicalSearches |
number | Image search count since last restart |
averageTextualSearchesPerSession |
number | Text searches / sessions ratio |
averageGraphicalSearchesPerSession |
number | Image searches / sessions ratio |
searchesWithoutResults |
number | Searches, text and image together, that SearXNG answered with zero results and no unresponsive engines |
searchesWithUnresponsiveEngines |
number | Searches, text and image together, that came back with zero results and unresponsive engines, whether the retries were spent or an all-suspended set failed fast; one per search, not per attempt |
searchesWithAllResultsDiscarded |
number | Text searches whose results were all dropped during processing |
biEncoderServiceStatus |
string | "healthy" or "unhealthy" |
rerankerServiceStatus |
string | "healthy" or "unhealthy" |
webSearchServiceStatus |
string | "healthy", "degraded" or "unhealthy", see below |
pageReads |
object | Page-reading counters since last restart, see below |
authorization |
object | Token and rate-limit outcomes since last restart, see below |
inference |
object | AI answer counters since last restart, see below |
searches |
object | Search timing, circuit state and per-engine failures, see below |
reranker |
object | Reranking cost and effect, see below |
thumbnails |
object | /thumbnail request outcomes, see below |
build.timestamp |
string | ISO 8601 build time |
build.gitCommit |
string | Short Git commit hash |
webSearchServiceStatus answers whether searches can be served, which
SearXNG's /healthz alone does not: an open circuit fails every search without
calling SearXNG at all, so the probe keeps answering OK straight through a
total outage, and engines under suspension answer 200 with nothing usable. So
unhealthy means the probe failed or the circuit is open. degraded means the
last search of either type was lost to its engines, or the circuit is half-open,
which it reaches on a timer with no successful search behind it. Neither covers
a search that failed outright while the circuit was still closed, which stays
healthy until the fifth consecutive failure opens it. A degradation is tracked
per search type, because text and image searches go out to different engine
pools and the client fires an image search straight after a failed text one;
searches.degradedSearchTypes names the ones currently flagged. It clears as
soon as SearXNG answers a search of that type, zero results included: the
engines replied, so whatever was failing is not failing now.
rerankerServiceStatus and biEncoderServiceStatus stay plain liveness probes,
since neither sits behind a breaker and a failing one degrades a search instead
of losing it.
The three searches... counters are the aggregate form of the log lines that
used to carry the query text. The log still names the unresponsive engines
behind an empty response and the size and type of a discarded batch; how often
each happens is read from here instead. searchesWithoutResults counts only
the searches that genuinely matched nothing, since an empty response naming
unresponsive engines fails the search rather than returning zero results,
after the retries are spent or immediately for an all-suspended set
(see docs/failure-injection.md);
searchesWithUnresponsiveEngines counts those, which is the way to tell
whether the case is being over-classified. It undercounts a sustained
outage, where the circuit breaker short-circuits before performSearch is
reached, and it does not count a search that recovered on a retry at all,
which is the point of the retry. The discarded count covers image searches too:
a result SearXNG returned without a thumbnail URL is dropped here, since the
grid has nothing to show for it. A thumbnail whose URL is there but cannot be
loaded is a later, per-tile failure, handled when the client fetches it from
/thumbnail.
pageReads reports what happened to the pages read for AI answers. Each field
is attached to a constant that someone will want to move, which is the reason it
is counted at all (see docs/page-content.md):
| Field | Type | Tunes |
|---|---|---|
requested |
number | Nothing; the denominator for the rest |
read |
number | Nothing; how often the feature contributed anything |
readRate |
number | Nothing; read as a percentage of requested |
averageReadMs |
number | REQUEST_TIMEOUT_MS, including the reads that hit it |
bodiesTruncated |
number | MAX_RESPONSE_BYTES |
excerptKeptRate |
number | MAX_PAGE_CHARS and the 0.9 dedup threshold, as the share of pooled passages that survive |
circuitOpens |
number | failureThreshold on the page-read host breaker: how often a host was boxed, summed over every host, since no host is named |
skipped.blocked |
number | The SSRF guard, and how often callers aim at private space |
skipped.skippedByBreaker |
number | resetTimeout on the host breaker: reads refused on the server because the host had refused the last three, which is what the box saved |
skipped.notADocument |
number | READABLE_CONTENT_TYPES |
skipped.httpForbidden |
number | Bot walls and rate limits (401, 403, 429) |
skipped.httpNotFound |
number | Dead links (404, 410) |
skipped.httpOtherError |
number | Other non-ok HTTP statuses |
skipped.redirectLimit |
number | MAX_REDIRECTS |
skipped.timedOut |
number | REQUEST_TIMEOUT_MS |
skipped.tooLittleText |
number | MIN_USEFUL_CHARS, and the extractor's selectors |
skipped.failed |
number | Nothing; the residue worth watching for a pattern |
grounding.requests |
number | Nothing; the denominator for the two below |
grounding.withContent |
number | Nothing; requests where at least one page yielded text |
grounding.withoutContent |
number | The budgets and timeouts above, read per request instead of per page: six pages at a 50% read rate can be three fully grounded answers or six half-grounded ones |
read plus every skipped entry sums to requested, so a page that goes
uncounted shows up as a gap rather than being lost silently.
grounding counts /page-content requests and not searches. A search only
reaches that endpoint when the browser has AI responses on, page reading on,
and the search returned results, and AI responses are off by default, so
grounding.requests sitting at zero while textualSearches climbs means
nobody asked for an answer, not that nothing could be read.
authorization reports what happened to the requests that reached token
verification, the funnel /search/text, /search/images, /page-content and
/inference all pass through:
| Field | Type | Says |
|---|---|---|
requests |
number | Requests that reached verification; the denominator for the rest |
authorized |
number | Requests that passed it |
rejectedRate |
number | Rejections as a percentage of requests |
reasons.rateLimited |
number | Refused by the limiter before anything else ran |
reasons.missingToken |
number | No token on the request, which is what an outdated client looks like |
reasons.invalidToken |
number | A token that failed verification, which is what probing looks like |
bySurface |
object | authorized and rejected per endpoint family: search, pageContent, thumbnail, inference, other |
rejectedTokenCacheHits |
number | Rejections served from the rejected-token cache without a second argon2 verification |
limiter |
object | The shared limiter's points and durationSeconds, plus the separate thumbnail budget behind /thumbnail, without which a rejection count says nothing |
authorized plus every entry of reasons sums to requests, and each half of
bySurface sums to its side of that, on the same principle as pageReads.
rejectedTokenCacheHits counts a subset of reasons.invalidToken rather
than adding to that sum.
The limiter keys on the client IP and none of that reaches these counters: no
address, no token, no query, no per-request timestamp. The cut is by reason and
by surface, both properties of the request rather than of whoever sent it.
bySurface is the one worth watching: all five endpoints share one budget and
a single user action fans out into a text search, an image search, the
thumbnail loads behind the image grid and a page-content read, so its
authorized side says where the budget goes and its rejected side says who
pays for it running out.
inference reports what happened to the AI answers served through
/inference. Nothing about the conversation is kept, so the questions it can
answer are about the upstream and about the wait, not about what was asked:
| Field | Type | Says |
|---|---|---|
requests |
number | Requests that passed token verification; the denominator for the rest |
streamed |
number | Answers that finished normally |
streamedRate |
number | streamed as a percentage of requests |
averageTimeToFirstTokenMs |
number | The wait the user actually feels, over the answers that produced a token |
averageDurationMs |
number | Whole-request duration over every outcome, so a 2 ms rejected body and a 25 s answer land in the same average |
averageAttempts |
number | Models tried, over the requests that reached the model loop; above 1 means the pool is carrying failures |
modelFallbacks |
number | Times a retry moved to another model |
modelsRefetched |
number | Times the model pool was re-listed mid-retry |
streamsEndedWithoutFinish |
number | Attempts whose stream closed with no finish part, counted per attempt because the retry can still succeed |
failed.failedBeforeFirstToken |
number | Every model failed before a token was sent, answered 503 |
failed.failedMidStream |
number | The answer broke after content was already sent, so no retry was possible |
failed.abandoned |
number | The client was gone before or during the answer; the only outcome with no response to observe it by |
failed.badRequest |
number | Body too large, unparsable, or failing the schema |
failed.notConfigured |
number | INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL or its key is missing |
failed.modelListUnavailable |
number | The upstream's model list could not be fetched |
failed.noModelAvailable |
number | The list was fetched and held nothing usable |
failed.internalError |
number | Anything the handler did not expect |
failed.unclassified |
number | Should always be zero; a non-zero value means a path stopped reporting its outcome |
byModel |
object | attempted, streamed, failed and abandoned per model id, which is how a dead member of the pool becomes visible. attempted equals the other three added up |
streamed plus every failed entry sums to requests. A request refused at
token verification is not counted here, since authorization above already
counts it, and one refused for its method or its content type is answered
before either counter is reached. So inference.requests should equal
authorization.bySurface.inference.authorized, and a gap between the two is a
request that never finished at all.
Two fields read differently on a single-model deployment. With
INTERNAL_OPENAI_COMPATIBLE_API_MODEL set there is no pool to fall back to, so
the loop stops after the first failure: averageAttempts cannot exceed 1 and
modelFallbacks stays at 0 however badly the upstream behaves. The pool also
never refreshes in that mode, so modelsRefetched stays at 0; read
failed.failedBeforeFirstToken for that case instead.
Model ids are configuration rather than user data, and /inference already
sends the id to the browser in every chunk, so naming them here publishes
nothing new.
searches, reranker and thumbnails are the numbers behind the constants
in the search path. Each row names the constant it is there to move, the same
way pageReads does:
| Field | Type | Tunes |
|---|---|---|
searches.averageTextualMs |
number | The 30 s client timeout in client/modules/search.ts. Over the text searches SearXNG answered, so a failed or short-circuited one is not in it, and up to 7 s of retry backoff is |
searches.averageGraphicalMs |
number | The same, for image searches |
reranker.considered |
number | Nothing; results handed to the score filter, the denominator for keptRate |
reranker.kept |
number | Nothing; results that survived it |
searches.circuitState |
string | Nothing; whether searches are being short-circuited right now |
searches.degradedSearchTypes |
array | Nothing; which of text and images is currently flagged, so a degraded verdict says which engine pool it is about. Empty when the half-open circuit is what triggered the verdict, which circuitState in the same object names |
searches.circuitOpens |
number | failureThreshold and resetTimeout on the SearXNG breaker: each opening is a minute of serving no searches at all |
searches.unresponsiveEngines |
object | Nothing; failures and lastFailure per engine name, which is how one engine timing out is told apart from every engine being CAPTCHA-blocked. lastFailure is blocked (a CAPTCHA, a rate limit or an access denial), timeout or other, never SearXNG's own wording, which is free-form text from an upstream engine and does not belong on an endpoint that needs no token. Counted on the searches searchesWithUnresponsiveEngines counts, so a search that recovered on a retry is not in it |
reranker.reranks |
number | Nothing; the denominator for the rest |
reranker.averageMs |
number | Whether reranking or SearXNG is what users wait for, and so whether to rerank a shortlist instead of all 30 results. Over reranks, covering the filtering and sorting as well as the model |
reranker.keptRate |
number | kStandardDeviationFactor: near 100% means the filter is not filtering. Text and image reranks are pooled, and the two run through different paths, so this moves with the traffic mix as well as with the threshold |
reranker.byType.text, reranker.byType.images |
object | Each reports reranks, averageMs, considered, kept and keptRate for its search type; see docs/reranking.md. The existing top-level values remain combined totals |
reranker.fallbackApplied |
number | minPercentageFallback: a large share means the deviation threshold is emptying batches the fallback then has to rescue |
reranker.skippedUnhealthy |
number | Nothing; searches served in SearXNG's own order because the model was not loaded |
reranker.failed |
number | Nothing; the same, because reranking threw |
thumbnails.requested |
number | Nothing; the denominator for the two below; every verified /thumbnail request, cache hits included. Before the endpoint existed this counted image results in a search response, so the served share cannot be trended across that deploy |
thumbnails.dropped |
number | Every request that did not serve a tile: timeout, a non-raster or empty answer, or an address the guard refused (MAX_THUMBNAIL_BYTES is not one of these, a capped body is truncated and served): each is a tile the user saw as a placeholder |
thumbnails.blocked |
number | The SSRF guard, as the share of thumbnails whose host is in private space or does not resolve |
thumbnails.dropped includes the blocked ones, so requested minus dropped
is what reached the client.
MiniSearch uses a multi-layered client-side persistence strategy:
Two separate Dexie databases handle different persistence needs:
- SearchCacheDatabase (
client/modules/search.ts): Temporary cache for search results with TTL-based expiration. Table schema:textSearchHistory: Keyed by hashed query, indexed by timestampimageSearchHistory: Keyed by hashed query, indexed by timestamp- Cache config:
| Constant | Value | Description |
|---|---|---|
| TTL | 15 minutes | Freshness window: a hit inside it skips the network |
| MAX_STALE_RETENTION | 24 hours | How long an expired entry is kept, so a failed live search can still serve it flagged stale |
| MAX_ENTRIES | 100 | Maximum cached queries per store |
| ENABLED | true | Global cache toggle |
| PRUNE_INTERVAL | 10 | Cache writes between LRU prune passes |
| METRICS_LOG_INTERVAL | 10 | Operations between hit-rate log entries |
| REQUEST_TIMEOUT | 30,000 ms | Fetch timeout |
- Query hashing: djb2 XOR Murmur algorithm for cache key generation
- Management operations:
cleanExpiredCache,pruneCache,ensureIntegrity - Performance monitoring:
cacheMetricstracks hit/miss rates for text and image searches
- HistoryDatabase (
client/modules/history.ts): Long-term persistence of user interactions. Three coordinated tables:searches: Canonical log of each query with hydrated results payloadsllmResponses: AI answers tied to their originating search runchatHistory: Chronological chat turns scoped byconversationId(which equalssearchRunId)- Auto-cleanup: Enforces retention window and max entries, with pin protection
Lightweight state persisted across sessions via createLocalStoragePubSub pattern:
settings: Application preferences (inference type, model, UI options)querySuggestions: Shuffled search suggestion poollastSearchTokenHash: Cached security token hashmenuExpandedAccordions: UI state for settings menu sections
- Loads environment variables via
dotenv.config - Regenerates the search token used for CSRF protection, on build only
- Injects build metadata as compile-time replacements (
VITE_BUILD_DATE_TIME,VITE_COMMIT_SHORT_HASH) - Registers all middleware hooks (see Server Hook System above)
- Retrieves current settings via
getSettings() - Registers ready/close listeners on
historyDatabase, opens DB if history enabled - Sets up reactive listener to open/close DB when user toggles history in settings
- Creates React root and renders
<App />
useInitializeSettings: Merges default settings with stored values intosettingsPubSubuseAccessKeyValidation: ChecksVITE_ACCESS_KEYS_ENABLED; if enabled, verifies stored key; shows loading state during check; renders<AccessPage />or<MainPage />accordingly
MiniSearch supports optional access key authentication for restricting usage. When the ACCESS_KEYS environment variable is set, the server validates incoming requests against the configured keys. Rate limiting is applied to search and inference endpoints to prevent abuse.
Access keys are verified server-side before proxying requests to SearXNG or processing inference requests. The ACCESS_KEY_TIMEOUT_HOURS variable controls how long a valid access key remains cached.
For complete security details, see docs/security.md.
- HTTP API:
docs/api.md- Endpoint parameters, responses, and error codes - Quick Start:
docs/quick-start.md- Installation and first run - Configuration:
docs/configuration.md- All environment variables and settings - AI Integration:
docs/ai-integration.md- Detailed AI inference options - UI Components:
docs/ui-components.md- Component architecture and state management - Search History:
docs/search-history.md- History database and management - Conversation Memory:
docs/conversation-memory.md- Token budgeting and summaries - Security:
docs/security.md- Access control and privacy model - Development:
docs/development-commands.md- Available commands