Skip to content

Latest commit

 

History

History
517 lines (396 loc) · 35.2 KB

File metadata and controls

517 lines (396 loc) · 35.2 KB

MiniSearch Overview

System Purpose and Design Philosophy

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.

Core Technologies and Dependencies

MiniSearch integrates multiple technology stacks within a unified deployment container:

Frontend

  • 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

AI & Search

  • @wllama/wllama - Client-side AI inference (WebGPU-accelerated or CPU via WebAssembly)
  • AI SDK - AI integration layer
  • @ai-sdk/openai-compatible - Unified AI interface

Data & State

  • Dexie - IndexedDB management
  • create-pubsub - State management (avoid React Context)
  • usePubSub - Component subscriptions

Application Entry Points

The application has three primary entry points:

  1. Browser Entry: client/index.tsx initializes the React application, mounting the root component and setting up error boundaries.

  2. Server Entry: vite.config.ts configures the Vite development and preview servers, registering server hooks for search and inference endpoints.

  3. Container Entry: Dockerfile starts both SearXNG and the Node.js server in a single process via shell command composition.

Multi-Service Container Architecture

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.

State Management Architecture

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.

Data Persistence Strategy

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.

Development and Production Modes

The system supports two operational modes:

Development Mode

  • Hot module replacement (HMR) on port 7861
  • Volume mount for live code updates
  • Vite dev server with source maps

Production Mode

  • 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.

Search and AI Integration Flow

The system executes two parallel flows when a user submits a query:

Search Flow

  1. User submits a query via SearchForm
  2. Client checks IndexedDB cache for matching query hash
  3. On cache miss: authenticated HTTP request to /search/text or /search/images
  4. Server verifies request token via searchToken.ts (CSRF protection)
  5. webSearchService.ts forwards query to SearXNG at http://127.0.0.1:8888
  6. Raw results are deduplicated, cleaned, and optionally reranked
  7. 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
  8. Results returned as structured JSON and cached in IndexedDB (15-minute freshness TTL)

AI Generation Flow

  1. textGeneration.ts orchestrates response generation after search completes
  2. State machine transitions: idle -> loadingModel/preparingToGenerate -> awaitingSearchResults -> generating -> completed/failed/interrupted (see Text Generation States below; loadingModel only occurs on the browser/Wllama path, other backends use preparingToGenerate)
  3. Search results are formatted and injected into system prompt via {{searchResults}} placeholder. With enablePageContentFetch on, the pages behind the top results are read through /page-content first and their excerpts are appended to each result (see docs/page-content.md)
  4. LLM generates response with streaming tokens
  5. Response updates throttled to ~12 updates/sec via throttleit to prevent React render overload
  6. 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.

Web Search Service Reliability

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.

Search Token Lifecycle

CSRF protection uses a token the server owns for its lifetime:

  1. 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
  2. Storage: The token is held in memory for the life of the process, and recorded at {os.tempdir()}/minisearch-token with 0600 permissions
  3. 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
  4. Client Hashing: Client hashes token before sending in requests (never sends raw token)
  5. Verification: Server compares request hash against the token it is holding
  6. Caching: Verified tokens stored in server/verifiedTokens.ts (in-memory Map of token to last-seen time) to avoid redundant cryptographic operations
  7. 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.

Data Flow and Communication

MiniSearch uses a PubSub-based architecture where state flows through independent channels. Components subscribe only to the channels they need, minimizing unnecessary re-renders.

State Machine Transitions

Text Generation States:

  • idle - No active generation
  • awaitingModelDownloadAllowance - Waiting for user consent to download a browser model
  • loadingModel - Downloading or initializing the browser (Wllama) model
  • awaitingSearchResults - Waiting for search to complete before generating
  • preparingToGenerate - Building the prompt/request just before calling the inference backend (OpenAI-compatible, Internal API, and AI Horde paths)
  • generating - Streaming response tokens
  • interrupted - Generation was cancelled by the user
  • completed - Full response received
  • failed - Error occurred

Search States:

  • idle - No active search
  • running - Search in progress
  • completed - Results received
  • failed - Error occurred

API Request Authentication

  1. Client retrieves cached token hash from lastSearchTokenHashPubSub (localStorage-backed)
  2. If expired or missing, generates new hash from the searchToken in /api/config
  3. Request includes hashed token as query parameter
  4. Server hook verifies token against stored value
  5. On success, token added to verifiedTokens Set for subsequent requests

Response Throttling

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.

Side Effects

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

Build and Deployment Pipeline

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.

Server Hook System

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 at http://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-token used for CSRF protection on search requests.
  • server/verifiedTokens.ts: In-memory Map of 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.

Cache Control

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

Status Endpoint

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.

Data Persistence Architecture

MiniSearch uses a multi-layered client-side persistence strategy:

IndexedDB Databases

Two separate Dexie databases handle different persistence needs:

  1. SearchCacheDatabase (client/modules/search.ts): Temporary cache for search results with TTL-based expiration. Table schema:
    • textSearchHistory: Keyed by hashed query, indexed by timestamp
    • imageSearchHistory: 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: cacheMetrics tracks hit/miss rates for text and image searches
  1. HistoryDatabase (client/modules/history.ts): Long-term persistence of user interactions. Three coordinated tables:
    • searches: Canonical log of each query with hydrated results payloads
    • llmResponses: AI answers tied to their originating search run
    • chatHistory: Chronological chat turns scoped by conversationId (which equals searchRunId)
    • Auto-cleanup: Enforces retention window and max entries, with pin protection

localStorage Persistence

Lightweight state persisted across sessions via createLocalStoragePubSub pattern:

  • settings: Application preferences (inference type, model, UI options)
  • querySuggestions: Shuffled search suggestion pool
  • lastSearchTokenHash: Cached security token hash
  • menuExpandedAccordions: UI state for settings menu sections

Application Bootstrap Flow

Server-Side Bootstrap (vite.config.ts)

  1. Loads environment variables via dotenv.config
  2. Regenerates the search token used for CSRF protection, on build only
  3. Injects build metadata as compile-time replacements (VITE_BUILD_DATE_TIME, VITE_COMMIT_SHORT_HASH)
  4. Registers all middleware hooks (see Server Hook System above)

Client-Side Bootstrap (client/index.tsx)

  1. Retrieves current settings via getSettings()
  2. Registers ready/close listeners on historyDatabase, opens DB if history enabled
  3. Sets up reactive listener to open/close DB when user toggles history in settings
  4. Creates React root and renders <App />

App Component Initialization (client/components/App/App.tsx)

  1. useInitializeSettings: Merges default settings with stored values into settingsPubSub
  2. useAccessKeyValidation: Checks VITE_ACCESS_KEYS_ENABLED; if enabled, verifies stored key; shows loading state during check; renders <AccessPage /> or <MainPage /> accordingly

Access Control and Security

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.

Related Topics

  • 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