This document describes the runtime architecture, data model, and major subsystems of OxideSEO. It is intended for engineers contributing to or operating the system.
For the project overview, see README.md. For local development setup, see DEVELOPMENT.md.
OxideSEO is a cross-platform desktop application that crawls websites and evaluates them against SEO rules. It runs as a single Tauri v2 process hosting a system webview (React frontend) and a Rust backend. The backend contains a channel-based crawl engine that orchestrates HTTP fetching on tokio, HTML parsing and rule evaluation on rayon, and batched SQLite writes on a dedicated OS thread. The frontend communicates with the backend exclusively through Tauri IPC commands and event subscriptions. Optional AI analysis delegates to external LLM providers via user-provided API keys stored in the OS-native credential store. A plugin system supports WASM (sandboxed) and native (trusted) extensions.
OxideSEO runs as a single OS process with multiple internal threads and runtimes:
graph TD
subgraph "OxideSEO Process"
WV[System Webview<br/>React Frontend]
IPC[Tauri IPC Bridge<br/>serde JSON]
CMD[Command Handlers<br/>46 commands]
subgraph "Crawl Engine"
ORCH[Orchestrator<br/>tokio task]
FETCH[Fetch Workers<br/>tokio async]
PARSE[Parse + Rules<br/>rayon thread pool]
WRITER[Storage Writer<br/>dedicated OS thread]
FRONTIER[URL Frontier<br/>heap + SQLite overflow]
end
DB[(SQLite<br/>WAL mode)]
AI[AI Engine<br/>LLM adapters]
PLUG[Plugin Manager<br/>WASM + Native]
end
WV <-->|invoke / listen| IPC
IPC --> CMD
CMD --> DB
CMD --> AI
CMD --> PLUG
ORCH --> FETCH
FETCH --> PARSE
PARSE -->|StorageCommand channel| WRITER
WRITER --> DB
ORCH --> FRONTIER
AI -->|HTTPS| LLM[External LLM APIs]
FETCH -->|HTTPS| WEB[Target Websites]
| Component | Runtime | Concurrency | Purpose |
|---|---|---|---|
| Main thread | Tauri | 1 | Webview host, IPC dispatch |
| Crawl orchestrator | tokio task | 1 per crawl | URL scheduling, state machine |
| Fetch workers | tokio async | Configurable (default 8) | HTTP requests, redirect tracking |
| Parse + Rules | rayon pool | CPU count - 2 | HTML parsing, rule evaluation |
| Storage writer | OS thread | 1 (serialized writes) | Batched SQLite transactions |
| External link checker | tokio task | 1 | HEAD requests for outbound links |
| AI engine | tokio | Sequential per batch | LLM API calls with rate limiting |
No background daemons, cron jobs, or mandatory external service dependencies exist. AI providers are opt-in.
The crawl is the core operation. This section traces the complete data flow from user action to completion.
The user configures a crawl in the CrawlConfig form and clicks Start. The frontend calls startCrawl(config) via Tauri IPC.
In Rust (commands/crawl.rs):
- Validate the
CrawlConfig(URL format, regex pattern compilation) - Insert a new crawl record into SQLite with status
"running" - Call
spawn_crawl()to set up the engine and return aCrawlHandle - Store the handle in the global
CrawlHandlesmap (keyed by crawl ID) - Return the crawl ID to the frontend
spawn_crawl() in crawler/engine.rs creates all supporting infrastructure:
| Component | Type | Purpose |
|---|---|---|
| Fetcher | Arc<Fetcher> |
HTTP client (reqwest, manual redirect tracking, rustls-tls) |
| URL Frontier | Arc<Mutex<UrlFrontier>> |
Priority queue: in-memory max-heap (10K cap) + SQLite overflow, blake3 dedup |
| Politeness Controller | Arc<PolitenessController> |
Per-domain crawl delay (default 500ms) + per-host concurrency (default 2) |
| Robots Cache | Arc<Mutex<RobotsCache>> |
robots.txt fetch, cache (3600s TTL), Crawl-Delay extraction |
| Rule Registry | Arc<RuleRegistry> |
Built-in + plugin rules with user config overrides |
| Compiled Patterns | Arc<CompiledPatterns> |
URL include/exclude regex, rewrite rules |
| JS Renderer | Optional Arc<JsRenderer> |
Headless webview for SPA rendering |
| Storage channel | mpsc::channel(5000) |
Bounded channel to the storage writer thread |
| State channel | watch::Sender<CrawlState> |
Broadcast for pause/resume/stop signals |
The seed URL is enqueued into the frontier at depth 0, priority 100.
Pre-crawl sitemap discovery (if enabled): fetches robots.txt, discovers sitemap URLs from directives and well-known paths, fetches and parses all sitemaps in parallel, and enqueues discovered URLs at priority 150.
The main loop runs as a tokio task:
- Check state: If
Stopped-> break. IfPaused-> block untilRunningorStopped. - Pop URL from frontier: If empty and no in-flight requests -> crawl complete.
- robots.txt check: Fetch and cache per domain, skip if disallowed.
- Politeness wait: Honor per-domain crawl delay.
- Acquire permits: Global semaphore (default 50 max concurrent) + per-host semaphore (default 2).
- Spawn async worker task for the URL.
Fetch (tokio async):
- HTTP request with manual redirect following (up to 10 hops, each recorded)
- blake3 hash of response body (capped at 10MB)
- On error: write errored page row, increment counter, return
Parse (rayon thread pool):
parse_html()via lol_html (streaming) with scraper (DOM) fallback- Extracts: title, meta description, canonical, viewport, H1-H6, links, images, scripts, stylesheets, word count, body text
Rule evaluation (rayon, same dispatch):
rule_registry.evaluate_page()runs all enabled rules- Returns
Vec<Issue>with severity, category, message, detail
JS rendering (optional):
- Heuristic: few links + many scripts = likely SPA
- If triggered: render via hidden webview, re-parse, re-evaluate rules
Link enqueuing:
- For each internal, non-nofollow link within max depth: normalize URL, apply rewrite rules, compute blake3 hash, push to frontier with dedup check
Storage:
- Build
PageRow,Vec<LinkRow>,Vec<IssueRow> - Send as
StorageCommand::StorePagethrough the bounded channel
Runs on a dedicated OS thread (not tokio):
- Block on
mpsc::Receiver::blocking_recv() - Drain available messages via
try_recv()up to batch size (200) - Flush batch in a single SQLite transaction
- Special commands:
Flush(immediate),FlushAck(flush + oneshot ack),Shutdown(flush + exit)
The StorePage command is atomic: page upsert, link inserts, and issue inserts all commit in one transaction. The writer resolves auto-generated page IDs and sets them on related rows.
The orchestrator emits crawl://progress events at most every 250ms or 50 URLs:
CrawlProgress {
crawlId, urlsCrawled, urlsQueued, urlsErrored,
currentRps, elapsedMs, recentUrls, memoryRssBytes
}
The frontend subscribes via the useCrawlProgress hook and updates the Zustand crawlStore.
When the frontier is empty and no requests are in-flight:
- Close external checker channel, wait for drain
- Flush storage writer via
FlushAck(blocks until all pending writes are committed) - Run
PostCrawlAnalyzeron a blocking thread:- Duplicate titles, descriptions, H1s (cross-page GROUP BY queries)
- Broken internal links (links targeting 4xx/5xx pages)
- Orphan pages (no inbound internal links at depth > 0)
- Flush post-crawl issues
- Mark crawl as
"completed"in database - Emit final progress event
Orchestrator (tokio)
|
|-- spawns --> Fetch Workers (tokio async, N concurrent)
| |
| |-- dispatches --> Parse + Rules (rayon pool)
| | |
| | |-- sends --> StorageCommand (mpsc, cap 5000)
| | |
| | v
| | Storage Writer (OS thread)
| | |
| | v
| | SQLite (WAL mode)
| |
| |-- enqueues --> Frontier (Arc<Mutex<UrlFrontier>>)
|
|-- listens --> State Watch (pause/resume/stop)
|-- emits --> crawl://progress (Tauri event, ~4Hz)
|-- optionally --> External Checker (tokio task, HEAD requests)
The full schema lives in src-tauri/migrations/. Below is a summary of the core entities and their relationships.
| Entity | Purpose | Key Relations |
|---|---|---|
crawls |
Crawl session metadata (URL, config, status, stats) | Has many pages, issues |
pages |
Fetched page data (URL, title, meta, status, timing) | Belongs to crawl; has many links, issues |
links |
Internal hyperlinks (source, target, anchor, type) | Belongs to source page |
issues |
SEO rule violations (rule, severity, message) | Belongs to page |
sitemap_urls |
Sitemap entries discovered during crawl | Belongs to crawl |
external_links |
External link check results (HEAD/GET) | Belongs to crawl |
settings |
App configuration (key-value) | Standalone |
rule_config |
Per-profile rule overrides | References rule ID |
ai_analyses |
Cached AI analysis results per page | Belongs to crawl + page |
ai_usage |
Token/cost tracking per provider per crawl | Belongs to crawl |
ai_crawl_summaries |
Crawl-level AI summaries | Belongs to crawl |
plugins |
Plugin registry (enabled, config) | Standalone |
Notable patterns:
- URL deduplication uses blake3 hash as a UNIQUE constraint on the
pagestable - AI analysis caching uses blake3 hash of page body text -- identical content across pages or crawls returns cached results
- No soft deletes; crawl deletion cascades to pages, issues, and links
- All writes go through the single storage writer thread to avoid WAL contention
The Tauri IPC layer separates the React frontend from the Rust backend. This is the most important integration surface.
46 async Tauri commands organized into groups:
| Group | Count | Examples |
|---|---|---|
| Crawl lifecycle | 5 | start_crawl, pause_crawl, resume_crawl, stop_crawl, get_crawl_status |
| Result queries | 9 | get_crawl_results, get_issues, get_links, get_site_tree |
| Comparison | 4 | get_comparison_summary, get_page_diffs, get_issue_diffs, get_metadata_diffs |
| Settings | 4 | get_settings, set_settings, get_rule_config, set_rule_config |
| Export | 3 | export_data, save_crawl_file, open_crawl_file |
| AI | 14 | analyze_page, batch_analyze_pages, generate_crawl_summary |
| Plugins | 7 | list_plugins, enable_plugin, install_plugin_from_file |
Every command has a typed wrapper in src/lib/commands.ts.
| Event | Payload | Frequency |
|---|---|---|
crawl://progress |
CrawlProgress |
~4Hz during active crawl |
ai://progress |
BatchProgress |
Per page during batch analysis |
- All Rust types use
#[serde(rename_all = "camelCase")] - TypeScript types in
src/types/index.tsuse native camelCase - Changes must be synchronized in both files in the same commit
- ~50 types cross the boundary
- Sorting, filtering, and pagination always happen in Rust/SQLite
- Results return as
PaginatedResponse<T>with{ items, total, offset, limit }
| Service | Purpose | Code Location | Credentials | Failure Mode |
|---|---|---|---|---|
| OpenAI API | LLM-powered SEO analysis | ai/adapters/openai.rs |
OS keyring | Graceful: analysis skipped, error shown |
| Anthropic API | LLM-powered SEO analysis | ai/adapters/anthropic.rs |
OS keyring | Graceful: analysis skipped, error shown |
| Ollama (local) | Local LLM inference | ai/adapters/ollama.rs |
None | Graceful: connection error shown |
| Target websites | Sites being crawled | crawler/fetcher.rs |
None | Per-URL: error recorded, crawl continues |
All AI integrations are opt-in, BYOK (Bring Your Own Key). The app functions fully without any external service. No telemetry, analytics, or phone-home behavior exists.
Every rule (built-in and plugin) implements this trait:
pub trait SeoRule: Send + Sync {
fn id(&self) -> &str;
fn name(&self) -> &str;
fn category(&self) -> RuleCategory;
fn default_severity(&self) -> Severity;
fn evaluate(&self, page: &ParsedPage, ctx: &CrawlContext) -> Vec<Issue>;
fn config_schema(&self) -> Option<serde_json::Value> { None }
fn configure(&mut self, params: &serde_json::Value) -> Result<()> { Ok(()) }
}| Category | Count | Rules |
|---|---|---|
| Meta | 7 | TitleMissing, TitleLength, DescMissing, DescLength, CanonicalMissing, CanonicalMismatch, ViewportMissing |
| Content | 4 | H1Missing, H1Multiple, HeadingHierarchy, ThinContent |
| Links | 3 | BrokenInternal, RedirectChain, NofollowInternal |
| Images | 2 | AltMissing, AltEmpty |
| Performance | 3 | LargePage, SlowResponse, RenderBlocking |
| Security | 2 | MixedContent, HttpPage |
After all pages are committed (synchronized via FlushAck), PostCrawlAnalyzer runs cross-page rules:
- Duplicate titles, descriptions, and H1s (GROUP BY queries)
- Broken internal links (links targeting 4xx/5xx pages)
- Orphan pages (pages with no inbound internal links at depth > 0)
Users override per-rule settings through the Settings view:
- Enabled/disabled per rule
- Severity override (Error, Warning, Info)
- Parameters for configurable rules (e.g., TitleLength min/max)
Configuration is stored in the rule_config table and applied at crawl start via RuleRegistry::apply_config().
| Runtime | Sandboxing | Use Case |
|---|---|---|
| WASM (wasmtime Component Model) | Fuel metering (10M instructions), memory limit (64MB), capability gating | Community plugins, untrusted code |
| Native (libloading, C ABI) | None -- full trust required | First-party plugins, verified vendors |
| Kind | Purpose |
|---|---|
| Rule | Custom SEO rule implementing the SeoRule trait |
| Exporter | Custom export format (receives crawl data as JSON, writes to file) |
| PostProcessor | Post-crawl analysis with read-only SQL access (WASM) or full DB access (native) |
| UiExtension | Frontend UI slot contribution |
- Discovery: Scan
{app_data_dir}/plugins/*/plugin.toml, validate manifests against app version - Load: Compile WASM components or load native dynamic libraries for enabled plugins
- Register: Rule plugins added to
RuleRegistry; other kinds registered inPluginManager - Execute: Rules called per-page during crawl; exporters on export; post-processors after crawl
- Uninstall: Remove from filesystem, database, and in-memory map
- Fuel metering: Each
evaluate()call gets a fuel budget (default 10M instructions). Exceeding it traps safely. - Memory limit: 64MB per instance (configurable in manifest).
- Capabilities: Plugins declare required capabilities in
plugin.toml. Only declared capabilities are granted.
| Capability | Description |
|---|---|
log |
Write log messages to the host tracing system |
http_read |
Make outbound HTTP GET requests |
db_read |
Execute read-only SQL queries (validated for safety) |
fs_read_plugin_dir |
Read files within the plugin's own directory |
Native plugins execute arbitrary compiled code. They require trusted = true in their manifest and are loaded via the C-ABI constructor oxide_seo_create_rule. Native plugins must be compiled with the same Rust toolchain version as the host application due to vtable layout instability.
- Stable:
PluginParsedPagefields (append-only), WIT world names, manifest format, issue namespace (plugin.*), C-ABI constructor symbol - Experimental: UI extension slots, post-processor SQL interface, exporter data format
The WIT interface follows semver. Deprecated features remain functional for at least 2 minor releases.
For plugin development details, see DEVELOPMENT.md.
- User configures a provider (OpenAI, Anthropic, or Ollama) and model in Settings
- API key is stored in the OS-native credential store (Ollama requires no key)
- Single-page or batch analysis triggered from the Results Explorer
- Per page: build prompt from template, check content-hash cache, call LLM on cache miss
- Response validated (JSON), cached with blake3 content hash, cost tracked
- Budget enforcement: 90% of
max_tokens_per_crawlallocated to batch, 10% reserved for summary
| Type | Purpose |
|---|---|
content_score |
Content quality scoring (readability, relevance, depth) |
meta_desc |
Meta description generation (150-160 characters) |
title_tag |
Title tag suggestions (50-60 characters) |
structured_data |
JSON-LD schema recommendations |
accessibility |
WCAG 2.1 remediation guidance |
- Token budget per crawl (configurable in settings)
- Blake3 content-hash dedup prevents re-analyzing identical pages
- 200ms delay between LLM requests to prevent rate limit bursts
- Pre-batch cost estimation via
estimate_batch_cost() - Real-time progress via
ai://progressevents
The app uses client-side view switching (not URL-based routing). App.tsx maintains:
activeView:AppViewunion type (dashboard, crawl-config, crawl-monitor, results, plugins, settings, crawl-comparison)activeCrawlIdand optionalcompareCrawlId
The navigate(view, crawlId?, compareCrawlId?) callback propagates to all components via props.
| Layer | Tool | Scope | Used For |
|---|---|---|---|
| Global client state | Zustand | App lifetime | Active crawl ID, progress snapshot, settings |
| Local component state | useState | Component lifetime | Form inputs, tab selection, UI toggles |
| Server state | Rust/SQLite via IPC | Persistent | All crawl data, results, configuration |
| Real-time events | Tauri listen | Subscription lifetime | Crawl progress, AI batch progress |
Result tables use useServerData<T, F>() for server-driven pagination:
- Component provides a fetcher function, filters, and sort state
- Hook calls the Rust backend with
{ offset, limit, sortBy, sortDir, ...filters } - Backend executes parameterized SQL and returns
PaginatedResponse<T> - TanStack Virtual renders only visible rows (~50 DOM nodes regardless of dataset size)
- Infinite scroll triggers
loadMore()when the user approaches the bottom
| Module | Responsibility | Runtime |
|---|---|---|
commands/ |
Tauri IPC handlers. Thin wrappers that validate input, call into other modules, and map errors to Result<T, String>. |
tokio (main) |
crawler/ |
Crawl engine. Orchestrator, fetch workers, URL frontier, politeness, robots.txt, sitemap parsing, JS rendering. | tokio + rayon |
rules/ |
SEO rule evaluation. SeoRule trait, rule registry, built-in rules, post-crawl analysis. |
rayon |
storage/ |
SQLite layer. Database init, migrations, all SQL queries, data models, batched writer thread. | Dedicated OS thread (writes), any thread (reads) |
ai/ |
LLM integration. Provider trait, adapters, prompt templates, keystore, caching, budget enforcement. | tokio |
plugin/ |
Plugin system. Discovery, manifest parsing, WASM host (wasmtime), native host (libloading). | tokio + wasmtime |
| Directory | Responsibility |
|---|---|
components/layout/ |
App shell: Dashboard, Sidebar |
components/crawl/ |
CrawlConfig form, CrawlMonitor, ResourceMeter |
components/results/ |
ResultsExplorer (tabbed), DataTable (virtualized), tab components, filter bars, PageDetail |
components/comparison/ |
Crawl comparison views |
components/export/ |
ExportDialog |
components/plugins/ |
Plugin manager |
components/settings/ |
Settings, AI provider configuration |
components/ui/ |
shadcn/ui primitives (12 components) |
hooks/ |
useServerData, useCrawlProgress, useAiProgress, useTheme, usePluginExtensions |
lib/ |
commands.ts (IPC wrappers), validation.ts (Zod schemas), utils.ts (formatting) |
stores/ |
Zustand: crawlStore, settingsStore |
types/ |
TypeScript interfaces matching Rust IPC types |
npx tauri dev runs the Vite dev server (port 1420) and the Rust backend concurrently with hot reload on both sides.
npx tauri build runs tsc -b && vite build for the frontend, then cargo build --release for the backend, and bundles everything into platform-specific installers.
| Platform | Formats |
|---|---|
| macOS | .dmg, .app |
| Windows | .msi, .exe (NSIS) |
| Linux | .deb, .AppImage, .rpm |
The plugin-wasm feature (default) adds ~5-10MB to the binary for the wasmtime runtime. Build without it via cargo build --no-default-features.
The app registers .seocrawl files (MIME: application/x-seocrawl). These are portable SQLite databases containing a complete crawl.
Hidden Coupling and Footguns
Adding or changing a field on any IPC type requires updating both src-tauri/src/ (Rust struct with serde) and src/types/index.ts (TypeScript interface). The TypeScript side can silently accept extra or missing fields. The ResultsTab type is duplicated in both ResultsExplorer.tsx and ExportDialog.tsx.
Adding a field to PageRow touches 6+ locations: storage/models.rs (struct), queries.rs (UPSERT SQL + all SELECT queries + row_to_page mapper), types/index.ts, and every PageRow { ... } construction in writer.rs, post_crawl.rs, and engine.rs.
Any file that calls .manage(), .path(), .emit(), or similar methods on AppHandle or App requires use tauri::Manager;. The compiler error says "method not found" rather than "trait not in scope."
The storage writer processes commands in FIFO order within batches. FlushAck is the only synchronization primitive. Without it, reads may return stale data (e.g., post-crawl analysis running before all pages are committed).
HTML parsing and rule evaluation run on rayon (CPU-bound). HTTP fetching and event emission run on tokio (async I/O). Mixing them degrades throughput. This boundary is enforced by convention.
Native plugins use Rust dyn trait objects with an unstable vtable layout. They must be compiled with the same Rust toolchain version as the host application.
get_memory_rss() in crawler/engine.rs uses manually defined structs and task_info() on macOS, and reads /proc/self/status on Linux. The mach2 crate was intentionally avoided due to struct layout mismatches.
tauri::generate_context!() panics at compile time if src-tauri/icons/ is missing. Run npx tauri icon app-icon.png to generate icons from the source PNG.
- Logging:
tracing+tracing-subscriberwith JSON output. Default levelinfo, override viaRUST_LOGenv var. - Resource monitoring: Memory RSS gauge via platform-specific APIs, displayed in the CrawlMonitor ResourceMeter component.
- Error tracking: Not configured.
- Metrics: Not configured.