Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
1use axum::{http::HeaderValue, middleware as axum_middleware, Router};
2use minijinja::Environment;
3use sqlx::SqlitePool;
4use std::path::PathBuf;
5use std::sync::Arc;
6use tower_http::compression::CompressionLayer;
7use tower_http::services::ServeDir;
8use tower_http::set_header::SetResponseHeaderLayer;
9
10use crate::routes;
11use crate::{db, middleware, templates};
12
13/// A current desktop Chrome string. Outbound data requests carry this so the
14/// public upstreams (Yahoo, SEC) see an ordinary-looking browser.
15/// Override with FINANCE_USER_AGENT.
16const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
17 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
18
19#[derive(Clone)]
20pub struct AppState {
21 pub env: Arc<Environment<'static>>,
22 pub pool: SqlitePool,
23 pub config: Arc<Config>,
24 /// Live-data pub/sub hub: shared by the `/stream` route and the scheduler.
25 pub hub: Arc<crate::stream::Hub>,
26}
27
28#[derive(Debug, Clone)]
29pub struct Config {
30 /// Project root: where `templates/` and `dist/` live.
31 pub root: PathBuf,
32 /// Where `db.sqlite3` lives.
33 pub data_dir: PathBuf,
34 /// Absolute origin for sitemap / og tags. No trailing slash. May be empty.
35 pub base_url: String,
36 pub site_title: String,
37 /// User-Agent sent on every outbound data request.
38 pub user_agent: String,
39 /// Appended to the User-Agent on sec.gov requests so SEC can identify us.
40 pub sec_contact_email: String,
41 /// Which `QuoteProvider` impl to use for live data.
42 pub quote_provider: String,
43}
44
45impl AppState {
46 pub async fn from_env() -> anyhow::Result<Self> {
47 let root: PathBuf = std::env::var("FINANCE_ROOT")
48 .map(PathBuf::from)
49 .unwrap_or_else(|_| PathBuf::from("."));
50 let data_dir = std::env::var("FINANCE_DATA_DIR")
51 .map(PathBuf::from)
52 .unwrap_or_else(|_| root.join("data"));
53 std::fs::create_dir_all(&data_dir)?;
54
55 let base_url = std::env::var("BASE_URL").unwrap_or_default();
56 let site_title =
57 std::env::var("FINANCE_TITLE").unwrap_or_else(|_| "Finance".to_string());
58 let user_agent = std::env::var("FINANCE_USER_AGENT")
59 .ok()
60 .filter(|s| !s.is_empty())
61 .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string());
62 let sec_contact_email = std::env::var("SEC_CONTACT_EMAIL").unwrap_or_default();
63 let quote_provider = std::env::var("FINANCE_QUOTE_PROVIDER")
64 .ok()
65 .filter(|s| !s.is_empty())
66 .unwrap_or_else(|| "yahoo".to_string());
67
68 let pool = db::init(&data_dir).await?;
69
70 let templates_dir = root.join("templates");
71 let manifest_path = root.join("dist/.vite/manifest.json");
72 let env = Arc::new(templates::build_env(&templates_dir, &manifest_path));
73
74 let config = Arc::new(Config {
75 root,
76 data_dir,
77 base_url,
78 site_title,
79 user_agent,
80 sec_contact_email,
81 quote_provider,
82 });
83
84 let hub = Arc::new(crate::stream::Hub::new());
85
86 Ok(Self {
87 env,
88 pool,
89 config,
90 hub,
91 })
92 }
93}
94
95pub fn router(state: AppState) -> Router {
96 let dist_dir = state.config.root.join("dist");
97
98 let static_cache = SetResponseHeaderLayer::if_not_present(
99 axum::http::header::CACHE_CONTROL,
100 HeaderValue::from_static("public, max-age=31536000"),
101 );
102
103 Router::new()
104 .merge(routes::home::router())
105 .merge(routes::symbols::router())
106 .merge(routes::watchlist::router())
107 .merge(routes::search::router())
108 .merge(routes::stream::router())
109 .merge(routes::health::router())
110 .merge(routes::seo::router())
111 .nest_service(
112 "/static",
113 tower::ServiceBuilder::new()
114 .layer(static_cache)
115 .service(ServeDir::new(&dist_dir)),
116 )
117 .fallback(middleware::not_found)
118 .layer(axum_middleware::from_fn(middleware::log_requests))
119 // Compress HTML / JSON / static text on the fly (brotli or gzip per
120 // Accept-Encoding). tower-http's default predicate already skips
121 // `text/event-stream`, so `/stream` SSE frames still flush unbuffered.
122 .layer(CompressionLayer::new())
123 .with_state(state)
124}