| 51 |
51 |
|
#[cfg(test)]
|
| 52 |
52 |
|
mod deploy_lint;
|
| 53 |
53 |
|
|
| 54 |
|
- |
use axum::{http::HeaderValue, middleware, Router};
|
|
54 |
+ |
use axum::{extract::FromRef, http::HeaderValue, middleware, Router};
|
| 55 |
55 |
|
use std::time::Instant;
|
| 56 |
56 |
|
use tower_http::limit::RequestBodyLimitLayer;
|
| 57 |
57 |
|
use tower_http::services::ServeDir;
|
| 77 |
77 |
|
use storage::StorageBackend;
|
| 78 |
78 |
|
use webauthn_rs::Webauthn;
|
| 79 |
79 |
|
|
| 80 |
|
- |
/// Application state shared across all handlers
|
| 81 |
|
- |
#[derive(Clone)]
|
|
80 |
+ |
/// Application state shared across all handlers.
|
|
81 |
+ |
///
|
|
82 |
+ |
/// `#[derive(FromRef)]` generates `FromRef<AppState>` for every field type, so a
|
|
83 |
+ |
/// handler can extract just the slice it needs — `State<PgPool>`, `State<Config>`,
|
|
84 |
+ |
/// `State<AppStorage>`, `State<EmailClient>`, etc. — instead of the whole struct.
|
|
85 |
+ |
/// This narrows each handler's declared dependencies without changing the runtime
|
|
86 |
+ |
/// (the state is still one `Clone`-cheap value shared by every task). See
|
|
87 |
+ |
/// `_private/docs/mnw/appstate-decomposition-plan.md`.
|
|
88 |
+ |
#[derive(Clone, FromRef)]
|
| 82 |
89 |
|
pub struct AppState {
|
| 83 |
90 |
|
pub db: sqlx::PgPool,
|
| 84 |
91 |
|
pub config: Config,
|
| 96 |
103 |
|
pub scanner: Option<Arc<ScanPipeline>>,
|
| 97 |
104 |
|
pub webauthn: Arc<Webauthn>,
|
| 98 |
105 |
|
pub syntax: Option<Arc<git::SyntaxHighlighter>>,
|
|
106 |
+ |
// Plain timestamp types: skip FromRef so no handler extracts a bare
|
|
107 |
+ |
// `State<DateTime<Utc>>` / `State<Instant>` and to keep those types free for
|
|
108 |
+ |
// an `Ops` slice in Phase 1.
|
|
109 |
+ |
#[from_ref(skip)]
|
| 99 |
110 |
|
pub started_at: chrono::DateTime<chrono::Utc>,
|
|
111 |
+ |
#[from_ref(skip)]
|
| 100 |
112 |
|
pub start_instant: Instant,
|
| 101 |
113 |
|
/// HTTP client for the Multithreaded internal API (community/thread provisioning).
|
| 102 |
114 |
|
pub mt_client: Option<mt_client::MtClient>,
|
| 172 |
184 |
|
pub git_smart_http_semaphore: Arc<tokio::sync::Semaphore>,
|
| 173 |
185 |
|
}
|
| 174 |
186 |
|
|
|
187 |
+ |
/// Externally-wired dependencies handed to [`AppState::build`].
|
|
188 |
+ |
///
|
|
189 |
+ |
/// Holds every field the caller must construct from the environment (pools,
|
|
190 |
+ |
/// clients, loaded config, the warmed domain cache). [`AppState::build`] assembles
|
|
191 |
+ |
/// these into an [`AppState`] and fills in the purely-derived in-memory state
|
|
192 |
+ |
/// (start timestamps, the empty cache maps, the concurrency semaphores) itself, so
|
|
193 |
+ |
/// those defaults live in one place instead of being spelled out at each call site.
|
|
194 |
+ |
pub struct AppStateParts {
|
|
195 |
+ |
pub db: sqlx::PgPool,
|
|
196 |
+ |
pub config: Config,
|
|
197 |
+ |
pub storage: AppStorage,
|
|
198 |
+ |
pub stripe: Option<Arc<dyn PaymentProvider>>,
|
|
199 |
+ |
pub email: EmailClient,
|
|
200 |
+ |
pub docs: Arc<DocLoader>,
|
|
201 |
+ |
pub tier_prices: tier_prices::TierPrices,
|
|
202 |
+ |
pub runway_config: tier_prices::RunwayConfig,
|
|
203 |
+ |
pub pricing_comparison: pricing_comparison::PricingComparison,
|
|
204 |
+ |
pub scanner: Option<Arc<ScanPipeline>>,
|
|
205 |
+ |
pub webauthn: Arc<Webauthn>,
|
|
206 |
+ |
pub syntax: Option<Arc<git::SyntaxHighlighter>>,
|
|
207 |
+ |
pub mt_client: Option<mt_client::MtClient>,
|
|
208 |
+ |
pub wam: Option<wam_client::WamClient>,
|
|
209 |
+ |
/// Custom-domain cache, already warmed from the DB by the caller.
|
|
210 |
+ |
pub domain_cache: Arc<DashMap<String, db::UserId>>,
|
|
211 |
+ |
pub metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
|
|
212 |
+ |
pub page_view_tx: db::page_views::PageViewTx,
|
|
213 |
+ |
pub bg: background::BackgroundTx,
|
|
214 |
+ |
}
|
|
215 |
+ |
|
| 175 |
216 |
|
impl AppState {
|
|
217 |
+ |
/// Assemble an [`AppState`] from its externally-wired dependencies.
|
|
218 |
+ |
///
|
|
219 |
+ |
/// The caller builds the pieces that depend on the environment (DB pool, S3
|
|
220 |
+ |
/// clients, Stripe, loaded docs, derived pricing, the warmed domain cache) and
|
|
221 |
+ |
/// passes them in via [`AppStateParts`]; this fills in the purely-derived
|
|
222 |
+ |
/// in-memory state (start timestamps, empty session/SSE caches, concurrency
|
|
223 |
+ |
/// semaphores sized from [`constants`]). It has no process-global side effects:
|
|
224 |
+ |
/// `TierPrices::install_global` is an explicit step at the call site, kept out
|
|
225 |
+ |
/// of construction so the ordering stays visible.
|
|
226 |
+ |
pub fn build(parts: AppStateParts) -> Self {
|
|
227 |
+ |
Self {
|
|
228 |
+ |
db: parts.db,
|
|
229 |
+ |
config: parts.config,
|
|
230 |
+ |
storage: parts.storage,
|
|
231 |
+ |
stripe: parts.stripe,
|
|
232 |
+ |
email: parts.email,
|
|
233 |
+ |
docs: parts.docs,
|
|
234 |
+ |
tier_prices: parts.tier_prices,
|
|
235 |
+ |
runway_config: parts.runway_config,
|
|
236 |
+ |
pricing_comparison: parts.pricing_comparison,
|
|
237 |
+ |
scanner: parts.scanner,
|
|
238 |
+ |
webauthn: parts.webauthn,
|
|
239 |
+ |
syntax: parts.syntax,
|
|
240 |
+ |
started_at: chrono::Utc::now(),
|
|
241 |
+ |
start_instant: Instant::now(),
|
|
242 |
+ |
mt_client: parts.mt_client,
|
|
243 |
+ |
wam: parts.wam,
|
|
244 |
+ |
restart_at: Arc::new(std::sync::atomic::AtomicI64::new(0)),
|
|
245 |
+ |
metrics_handle: parts.metrics_handle,
|
|
246 |
+ |
page_view_tx: parts.page_view_tx,
|
|
247 |
+ |
bg: parts.bg,
|
|
248 |
+ |
caches: AppCaches {
|
|
249 |
+ |
session_cache: Arc::new(DashMap::new()),
|
|
250 |
+ |
domain_cache: parts.domain_cache,
|
|
251 |
+ |
sync_notify: Arc::new(DashMap::new()),
|
|
252 |
+ |
sse_connections: Arc::new(DashMap::new()),
|
|
253 |
+ |
},
|
|
254 |
+ |
limiters: AppLimiters {
|
|
255 |
+ |
scan_semaphore: Arc::new(tokio::sync::Semaphore::new(constants::SCAN_MAX_CONCURRENT)),
|
|
256 |
+ |
caddy_ask_semaphore: Arc::new(tokio::sync::Semaphore::new(constants::CADDY_ASK_MAX_CONCURRENT)),
|
|
257 |
+ |
git_smart_http_semaphore: Arc::new(tokio::sync::Semaphore::new(constants::GIT_SMART_HTTP_MAX_CONCURRENT)),
|
|
258 |
+ |
},
|
|
259 |
+ |
}
|
|
260 |
+ |
}
|
|
261 |
+ |
|
| 176 |
262 |
|
/// Get the main S3 storage backend, or error if not configured.
|
| 177 |
263 |
|
pub fn require_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
|
| 178 |
264 |
|
self.storage.s3
|
| 471 |
557 |
|
}
|
| 472 |
558 |
|
|
| 473 |
559 |
|
#[cfg(test)]
|
|
560 |
+ |
mod from_ref_slices {
|
|
561 |
+ |
//! Compile-time proof of the decomposition seam: `#[derive(FromRef)]` on
|
|
562 |
+ |
//! [`AppState`] generates `FromRef<AppState>` for each field type, so a Phase 2
|
|
563 |
+ |
//! handler can extract a narrow slice as `State<T>` instead of the whole
|
|
564 |
+ |
//! struct. If any of these stop being extractable (a field renamed/removed, the
|
|
565 |
+ |
//! derive dropped), this fails to compile — catching it before the handler
|
|
566 |
+ |
//! migrations do. Type-level only: no `AppState` value or DB required.
|
|
567 |
+ |
use super::*;
|
|
568 |
+ |
|
|
569 |
+ |
fn _assert_from_ref<T: FromRef<AppState>>() {}
|
|
570 |
+ |
|
|
571 |
+ |
#[test]
|
|
572 |
+ |
fn slices_are_extractable() {
|
|
573 |
+ |
_assert_from_ref::<sqlx::PgPool>();
|
|
574 |
+ |
_assert_from_ref::<Config>();
|
|
575 |
+ |
_assert_from_ref::<EmailClient>();
|
|
576 |
+ |
_assert_from_ref::<AppStorage>();
|
|
577 |
+ |
_assert_from_ref::<AppCaches>();
|
|
578 |
+ |
_assert_from_ref::<AppLimiters>();
|
|
579 |
+ |
}
|
|
580 |
+ |
}
|
|
581 |
+ |
|
|
582 |
+ |
#[cfg(test)]
|
| 474 |
583 |
|
mod timeout_exempt_tests {
|
| 475 |
584 |
|
use super::timeout_exempt;
|
| 476 |
585 |
|
|