Skip to main content

turbine/
lib.rs

1pub mod cache;
2pub mod config;
3pub mod dashboard;
4pub mod health;
5pub mod metrics;
6pub mod proxy;
7pub mod types;
8
9use config::{
10    ApiKeyConfig, CacheConfig, CacheMethodConfig, ChainConfig, Config, EndpointAuth,
11    EndpointConfig, HealthConfig, HedgeConfig, RateLimitConfig, RotationStrategy, ServerConfig,
12};
13use proxy::build_router;
14use std::path::Path;
15
16pub struct Turbine {
17    config: Config,
18}
19
20pub struct TurbineBuilder {
21    chains: Vec<ChainConfig>,
22    dashboard_secret: Option<String>,
23    api_keys: Vec<ApiKeyConfig>,
24}
25
26pub struct ChainBuilder {
27    name: String,
28    route: String,
29    endpoints: Vec<EndpointConfig>,
30    max_consecutive_failures: u32,
31    cooldown_seconds: u64,
32    health_method: Option<String>,
33    health_check_interval_seconds: u64,
34    max_block_lag: u64,
35    rotation: RotationStrategy,
36    cache_enabled: bool,
37    cache_preset: Option<String>,
38    cache_max_capacity: Option<u64>,
39    cache_methods: Vec<CacheMethodConfig>,
40    chain_id: Option<u64>,
41    max_retries: u32,
42    retry_delay_ms: u64,
43    rate_limit_max_requests: Option<u32>,
44    rate_limit_window_seconds: Option<u64>,
45    hedge_delay_ms: Option<u64>,
46    hedge_max_count: Option<u32>,
47    parent: TurbineBuilder,
48}
49
50impl Turbine {
51    /// Create a Turbine instance from a TOML config file.
52    pub fn from_config(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
53        let config = Config::load(path)?;
54        Ok(Self { config })
55    }
56
57    /// Create a Turbine instance from an existing Config.
58    pub fn from_raw_config(config: Config) -> Self {
59        Self { config }
60    }
61
62    /// Start building a Turbine instance programmatically.
63    pub fn builder() -> TurbineBuilder {
64        TurbineBuilder {
65            chains: Vec::new(),
66            dashboard_secret: None,
67            api_keys: Vec::new(),
68        }
69    }
70
71    /// Get the configured host.
72    pub fn host(&self) -> &str {
73        &self.config.server.host
74    }
75
76    /// Get the configured port.
77    pub fn port(&self) -> u16 {
78        self.config.server.port
79    }
80
81    /// Get an axum Router to embed in your own server.
82    pub fn into_router(self) -> axum::Router {
83        build_router(&self.config)
84    }
85
86    /// Run the proxy as a standalone server.
87    pub async fn serve(self, addr: &str) -> Result<(), Box<dyn std::error::Error>> {
88        let router = build_router(&self.config);
89        let listener = tokio::net::TcpListener::bind(addr).await?;
90        tracing::info!("Turbine starting on {}", addr);
91        axum::serve(listener, router).await?;
92        Ok(())
93    }
94}
95
96impl TurbineBuilder {
97    /// Add a chain to the proxy.
98    pub fn add_chain(self, name: &str) -> ChainBuilder {
99        ChainBuilder {
100            name: name.to_string(),
101            route: format!("/{}", name),
102            endpoints: Vec::new(),
103            max_consecutive_failures: 3,
104            cooldown_seconds: 30,
105            health_method: None,
106            health_check_interval_seconds: 30,
107            max_block_lag: 10,
108            rotation: RotationStrategy::RoundRobin,
109            cache_enabled: false,
110            cache_preset: None,
111            cache_max_capacity: None,
112            cache_methods: Vec::new(),
113            chain_id: None,
114            max_retries: 1,
115            retry_delay_ms: 0,
116            rate_limit_max_requests: None,
117            rate_limit_window_seconds: None,
118            hedge_delay_ms: None,
119            hedge_max_count: None,
120            parent: self,
121        }
122    }
123
124    /// Serve the live dashboard at `/{secret}` (e.g., `"abc123"` → `/abc123`).
125    ///
126    /// Omitting this disables the dashboard entirely. The secret path is the only
127    /// access control — choose something unguessable.
128    pub fn dashboard_secret(mut self, secret: &str) -> Self {
129        self.dashboard_secret = Some(secret.to_string());
130        self
131    }
132
133    /// Register an API key for inbound client authentication.
134    ///
135    /// When one or more keys are registered, **all** `/{chain}` requests must include
136    /// a valid key via `Authorization: Bearer <key>` or `X-Api-Key: <key>`.
137    /// Health (`/`), metrics, and dashboard routes remain open.
138    ///
139    /// - `name` — human-readable label used in logs
140    /// - `key` — the secret string clients must present
141    /// - `rate_limit` — optional `(max_requests, window_seconds)` quota for this key
142    ///
143    /// Invalid/missing key → HTTP 401. Per-key quota exceeded → HTTP 429.
144    /// Both responses use a JSON-RPC error body shape.
145    ///
146    /// # Example
147    ///
148    /// ```rust,no_run
149    /// # use turbine::Turbine;
150    /// Turbine::builder()
151    ///     .api_key("internal", "sk_internal_abc", None)          // unlimited
152    ///     .api_key("partner", "sk_partner_xyz", Some((500, 60))) // 500 req / 60s
153    ///     // ...add chains...
154    ///     # ;
155    /// ```
156    pub fn api_key(mut self, name: &str, key: &str, rate_limit: Option<(u32, u64)>) -> Self {
157        self.api_keys.push(ApiKeyConfig {
158            name: name.to_string(),
159            key: key.to_string(),
160            rate_limit: rate_limit.map(|(max_requests, window_seconds)| RateLimitConfig {
161                max_requests,
162                window_seconds,
163            }),
164        });
165        self
166    }
167
168    /// Build the Turbine instance.
169    pub fn build(self) -> Result<Turbine, Box<dyn std::error::Error>> {
170        if self.chains.is_empty() {
171            return Err("At least one chain must be configured".into());
172        }
173        let config = Config {
174            server: ServerConfig {
175                host: "127.0.0.1".to_string(),
176                port: 8080,
177                dashboard_secret: self.dashboard_secret,
178                api_keys: self.api_keys,
179            },
180            chains: self.chains,
181        };
182        Ok(Turbine { config })
183    }
184}
185
186impl ChainBuilder {
187    /// Add an RPC endpoint with default weight (1) and no auth.
188    pub fn endpoint(mut self, url: &str) -> Self {
189        self.endpoints.push(EndpointConfig {
190            url: url.to_string(),
191            weight: 1,
192            auth: None,
193            methods: None,
194            ws_url: None,
195        });
196        self
197    }
198
199    /// Add an RPC endpoint with a specific weight and no auth.
200    pub fn weighted_endpoint(mut self, url: &str, weight: u32) -> Self {
201        self.endpoints.push(EndpointConfig {
202            url: url.to_string(),
203            weight,
204            auth: None,
205            methods: None,
206            ws_url: None,
207        });
208        self
209    }
210
211    /// Add an RPC endpoint with HTTP Basic Auth (e.g., Bitcoin Core nodes).
212    pub fn endpoint_with_basic_auth(mut self, url: &str, username: &str, password: &str) -> Self {
213        self.endpoints.push(EndpointConfig {
214            url: url.to_string(),
215            weight: 1,
216            auth: Some(EndpointAuth::Basic {
217                username: username.to_string(),
218                password: password.to_string(),
219            }),
220            methods: None,
221            ws_url: None,
222        });
223        self
224    }
225
226    /// Add an RPC endpoint with a Bearer token.
227    pub fn endpoint_with_bearer(mut self, url: &str, token: &str) -> Self {
228        self.endpoints.push(EndpointConfig {
229            url: url.to_string(),
230            weight: 1,
231            auth: Some(EndpointAuth::Bearer(token.to_string())),
232            methods: None,
233            ws_url: None,
234        });
235        self
236    }
237
238    /// Add an RPC endpoint with a custom auth header (e.g., `x-api-key`).
239    pub fn endpoint_with_header(
240        mut self,
241        url: &str,
242        header_name: &str,
243        header_value: &str,
244    ) -> Self {
245        self.endpoints.push(EndpointConfig {
246            url: url.to_string(),
247            weight: 1,
248            auth: Some(EndpointAuth::Header {
249                name: header_name.to_string(),
250                value: header_value.to_string(),
251            }),
252            methods: None,
253            ws_url: None,
254        });
255        self
256    }
257
258    /// Add an RPC endpoint with an explicit WebSocket URL.
259    /// If not set, WSS URLs are auto-derived from the HTTP URL (https→wss, http→ws).
260    pub fn endpoint_with_ws(mut self, url: &str, ws_url: &str) -> Self {
261        self.endpoints.push(EndpointConfig {
262            url: url.to_string(),
263            weight: 1,
264            auth: None,
265            methods: None,
266            ws_url: Some(ws_url.to_string()),
267        });
268        self
269    }
270
271    /// Add an RPC endpoint that only receives the specified methods.
272    /// Requests for other methods will not be routed to this endpoint.
273    pub fn restricted_endpoint(mut self, url: &str, methods: &[&str]) -> Self {
274        self.endpoints.push(EndpointConfig {
275            url: url.to_string(),
276            weight: 1,
277            auth: None,
278            methods: Some(methods.iter().map(|s| s.to_string()).collect()),
279            ws_url: None,
280        });
281        self
282    }
283
284    /// Set a custom route (default: `/{name}`).
285    pub fn route(mut self, route: &str) -> Self {
286        self.route = route.to_string();
287        self
288    }
289
290    /// Set max consecutive failures before marking unhealthy (default: 3).
291    pub fn max_failures(mut self, n: u32) -> Self {
292        self.max_consecutive_failures = n;
293        self
294    }
295
296    /// Set cooldown in seconds before retrying an unhealthy endpoint (default: 30).
297    pub fn cooldown_secs(mut self, secs: u64) -> Self {
298        self.cooldown_seconds = secs;
299        self
300    }
301
302    /// Set the JSON-RPC method used for health checks (e.g., "eth_blockNumber", "getSlot").
303    pub fn health_method(mut self, method: &str) -> Self {
304        self.health_method = Some(method.to_string());
305        self
306    }
307
308    /// Set the health check interval in seconds (default: 30).
309    pub fn health_check_interval(mut self, secs: u64) -> Self {
310        self.health_check_interval_seconds = secs;
311        self
312    }
313
314    /// Set the max block lag before marking an endpoint as stale (default: 10).
315    pub fn max_block_lag(mut self, lag: u64) -> Self {
316        self.max_block_lag = lag;
317        self
318    }
319
320    /// Set rotation strategy to weighted.
321    pub fn weighted(mut self) -> Self {
322        self.rotation = RotationStrategy::Weighted;
323        self
324    }
325
326    /// Set rotation strategy to latency-based.
327    /// Routes more traffic to faster endpoints using inverse-latency weighting.
328    pub fn latency_based(mut self) -> Self {
329        self.rotation = RotationStrategy::Latency;
330        self
331    }
332
333    /// Enable or disable caching for this chain.
334    pub fn cache(mut self, enabled: bool) -> Self {
335        self.cache_enabled = enabled;
336        self
337    }
338
339    /// Set the cache preset ("evm", "solana", "none").
340    pub fn cache_preset(mut self, preset: &str) -> Self {
341        self.cache_preset = Some(preset.to_string());
342        self
343    }
344
345    /// Set the max number of cache entries.
346    pub fn cache_max_capacity(mut self, capacity: u64) -> Self {
347        self.cache_max_capacity = Some(capacity);
348        self
349    }
350
351    /// Add or override a cached method with a specific TTL in seconds.
352    pub fn cache_method(mut self, method: &str, ttl_seconds: u64) -> Self {
353        self.cache_methods.push(CacheMethodConfig {
354            name: method.to_string(),
355            ttl_seconds,
356        });
357        self
358    }
359
360    pub fn chain_id(mut self, id: u64) -> Self {
361        self.chain_id = Some(id);
362        self
363    }
364
365    pub fn max_retries(mut self, n: u32) -> Self {
366        self.max_retries = n;
367        self
368    }
369
370    pub fn retry_delay_ms(mut self, ms: u64) -> Self {
371        self.retry_delay_ms = ms;
372        self
373    }
374
375    pub fn rate_limit(mut self, max_requests: u32, window_seconds: u64) -> Self {
376        self.rate_limit_max_requests = Some(max_requests);
377        self.rate_limit_window_seconds = Some(window_seconds);
378        self
379    }
380
381    /// Enable hedged requests. After `delay_ms`, fire up to `max_count` additional
382    /// parallel requests to different endpoints. First success wins.
383    pub fn hedge(mut self, delay_ms: u64, max_count: u32) -> Self {
384        self.hedge_delay_ms = Some(delay_ms);
385        self.hedge_max_count = Some(max_count);
386        self
387    }
388
389    /// Finish configuring this chain and return to the builder.
390    pub fn done(self) -> TurbineBuilder {
391        let cache = if self.cache_enabled {
392            Some(CacheConfig {
393                enabled: true,
394                preset: self.cache_preset,
395                max_capacity: self.cache_max_capacity,
396                methods: self.cache_methods,
397            })
398        } else {
399            None
400        };
401
402        let rate_limit = match (self.rate_limit_max_requests, self.rate_limit_window_seconds) {
403            (Some(max_requests), Some(window_seconds)) => Some(RateLimitConfig {
404                max_requests,
405                window_seconds,
406            }),
407            _ => None,
408        };
409
410        let hedge = self.hedge_delay_ms.map(|delay_ms| HedgeConfig {
411            delay_ms,
412            max_count: self.hedge_max_count.unwrap_or(1),
413        });
414
415        let chain = ChainConfig {
416            name: self.name,
417            route: self.route,
418            endpoints: self.endpoints,
419            health: HealthConfig {
420                max_consecutive_failures: self.max_consecutive_failures,
421                cooldown_seconds: self.cooldown_seconds,
422                health_method: self.health_method,
423                health_check_interval_seconds: self.health_check_interval_seconds,
424                max_block_lag: self.max_block_lag,
425                max_retries: self.max_retries,
426                retry_delay_ms: self.retry_delay_ms,
427            },
428            rotation: self.rotation,
429            cache,
430            chain_id: self.chain_id,
431            rate_limit,
432            hedge,
433        };
434        let mut parent = self.parent;
435        parent.chains.push(chain);
436        parent
437    }
438}