Skip to main content

cratefield_runtime_native/
runtime.rs

1//! The `Native` runtime builder (issue #19): the counterpart of
2//! `cratefield_runtime_cloudflare::Cloudflare` for a self-hosted binary.
3//!
4//! ```ignore
5//! # use std::sync::Arc;
6//! # fn db() -> Arc<dyn cratefield_core::Database> { unimplemented!() }
7//! # fn rate_limiter() -> Arc<dyn cratefield_core::RateLimiter> { unimplemented!() }
8//! # fn kv() -> Arc<dyn cratefield_core::KeyValue> { unimplemented!() }
9//! # fn mailer() -> Arc<dyn cratefield_core::Mailer> { unimplemented!() }
10//! use cratefield_runtime_native::Native;
11//!
12//! let runtime = Native::new()
13//!     .db_arc(db())                       // Postgres (adapter-postgres) or Sqlite
14//!     .rate_limiter_arc(rate_limiter())   // RedisRateLimiter, optional
15//!     .kv_arc(kv())                       // RedisKv, optional
16//!     .mailer_arc(mailer());              // Resend::from_env(..), as on Workers
17//! # let _runtime: Native = runtime;
18//! ```
19
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23use cratefield_core::{
24    Blob, Captcha, Database, HarnessConfig, KeyValue, Mailer, Payments, Port, Ports, Push,
25    RateLimiter, Runtime, UlidIdGen,
26};
27
28use crate::config::EnvConfig;
29use crate::ports::{ReqwestClient, SpawnDefer, TokioClock};
30
31static WARNED_SIGNER: AtomicBool = AtomicBool::new(false);
32
33fn warn_once(flag: &AtomicBool, message: &str) {
34    if !flag.load(Ordering::Relaxed) {
35        flag.store(true, Ordering::Relaxed);
36        tracing::warn!("{message}");
37    }
38}
39
40/// The native runtime. Adapters are passed in as instances (the same
41/// pattern as the Cloudflare runtime's `.mailer(..)`/`.captcha(..)`):
42/// a Postgres or SQLite [`Database`](cratefield_core::Database) from the
43/// matching adapter crate, a [`RedisRateLimiter`](crate::RedisRateLimiter)
44/// and [`RedisKv`](crate::RedisKv) over a shared Redis connection
45/// manager, `Resend`/`Turnstile` unchanged from the Workers path.
46///
47/// Config — including `HARNESS_SECRET` for the `Signer` port — is read
48/// from `std::env` through the same [`cratefield_core::Config`] trait
49/// ([`EnvConfig`]), so module keys behave identically on both runtimes.
50#[derive(Clone, Default)]
51pub struct Native {
52    db: Option<Arc<dyn Database>>,
53    rate_limiter: Option<Arc<dyn RateLimiter>>,
54    kv: Option<Arc<dyn KeyValue>>,
55    blob: Option<Arc<dyn Blob>>,
56    push: Option<Arc<dyn Push>>,
57    payments: Option<Arc<dyn Payments>>,
58    mailer: Option<Arc<dyn Mailer>>,
59    captcha: Option<Arc<dyn Captcha>>,
60}
61
62impl Native {
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// The `Database` port: `Postgres` (`cratefield-adapter-postgres`) or
68    /// `SqliteDatabase` (`cratefield-adapter-sqlite`) — any `Database`
69    /// implementation.
70    #[must_use]
71    pub fn db(mut self, db: impl Database + 'static) -> Self {
72        self.db = Some(Arc::new(db));
73        self
74    }
75
76    /// `db` for an already-shared database.
77    #[must_use]
78    pub fn db_arc(mut self, db: Arc<dyn Database>) -> Self {
79        self.db = Some(db);
80        self
81    }
82
83    /// The `RateLimiter` port: [`RedisRateLimiter`](crate::RedisRateLimiter) in this crate, or a
84    /// test fake.
85    #[must_use]
86    pub fn rate_limiter(mut self, limiter: impl RateLimiter + 'static) -> Self {
87        self.rate_limiter = Some(Arc::new(limiter));
88        self
89    }
90
91    /// `rate_limiter` for an already-shared limiter.
92    #[must_use]
93    pub fn rate_limiter_arc(mut self, limiter: Arc<dyn RateLimiter>) -> Self {
94        self.rate_limiter = Some(limiter);
95        self
96    }
97
98    /// The `KeyValue` port: [`RedisKv`](crate::RedisKv) in this crate, or a test fake.
99    #[must_use]
100    pub fn kv(mut self, kv: impl KeyValue + 'static) -> Self {
101        self.kv = Some(Arc::new(kv));
102        self
103    }
104
105    /// `kv` for an already-shared store.
106    #[must_use]
107    pub fn kv_arc(mut self, kv: Arc<dyn KeyValue>) -> Self {
108        self.kv = Some(kv);
109        self
110    }
111
112    /// The `Blob` port: a directory store ([`crate::DirBlob`]) or an
113    /// S3-compatible adapter.
114    #[must_use]
115    pub fn blob_arc(mut self, blob: Arc<dyn Blob>) -> Self {
116        self.blob = Some(blob);
117        self
118    }
119
120    /// The `Push` port: an APNs adapter (`cratefield-adapter-apns`) or a test
121    /// fake. Notifications reach a device over the runtime's `HttpClient`.
122    #[must_use]
123    pub fn push_arc(mut self, push: Arc<dyn Push>) -> Self {
124        self.push = Some(push);
125        self
126    }
127
128    /// The `Payments` port: a Stripe adapter (`cratefield-adapter-stripe`) or a
129    /// test fake, over the runtime's `HttpClient`.
130    #[must_use]
131    pub fn payments_arc(mut self, payments: Arc<dyn Payments>) -> Self {
132        self.payments = Some(payments);
133        self
134    }
135
136    #[must_use]
137    pub fn mailer(mut self, mailer: impl Mailer + 'static) -> Self {
138        self.mailer = Some(Arc::new(mailer));
139        self
140    }
141
142    #[must_use]
143    pub fn mailer_arc(mut self, mailer: Arc<dyn Mailer>) -> Self {
144        self.mailer = Some(mailer);
145        self
146    }
147
148    #[must_use]
149    pub fn captcha(mut self, captcha: impl Captcha + 'static) -> Self {
150        self.captcha = Some(Arc::new(captcha));
151        self
152    }
153
154    #[must_use]
155    pub fn captcha_arc(mut self, captcha: Arc<dyn Captcha>) -> Self {
156        self.captcha = Some(captcha);
157        self
158    }
159
160    /// Resolves the port bundle once per process from the builder and
161    /// `std::env`: config, `Signer` (from `HARNESS_SECRET` — warn once
162    /// and leave unset when missing, exactly like the Cloudflare
163    /// runtime), `HttpClient` over reqwest, `Clock` over the system
164    /// clock, `IdGen`, and `Defer` over `tokio::spawn`.
165    ///
166    /// Unlike the Workers runtime — which resolves bindings per event —
167    /// a native process builds one bundle and shares it: every adapter
168    /// here is an `Arc`, so `clone_ports` snapshots cheaply.
169    #[must_use]
170    pub fn ports(&self) -> Ports {
171        let config: Arc<dyn cratefield_core::Config> = Arc::new(EnvConfig);
172        let mut ports = Ports::with_config(Arc::clone(&config));
173
174        ports.db.clone_from(&self.db);
175        ports.rate_limiter.clone_from(&self.rate_limiter);
176        ports.kv.clone_from(&self.kv);
177        ports.blob.clone_from(&self.blob);
178        ports.push.clone_from(&self.push);
179        ports.payments.clone_from(&self.payments);
180        ports.mailer.clone_from(&self.mailer);
181        ports.captcha.clone_from(&self.captcha);
182
183        match HarnessConfig::from_config(&*config) {
184            Ok(parsed) => ports.signer = Some(Arc::new(parsed.signer())),
185            Err(_) => warn_once(
186                &WARNED_SIGNER,
187                "HARNESS_SECRET missing or invalid: Signer port not provided",
188            ),
189        }
190
191        ports.http = Some(Arc::new(ReqwestClient::new()));
192        ports.clock = Some(Arc::new(TokioClock));
193        ports.id_gen = Some(Arc::new(UlidIdGen));
194        ports.defer = Some(Arc::new(SpawnDefer));
195        ports
196    }
197}
198
199impl Runtime for Native {
200    /// The static set used by `Harness::build`. `Signer` is included:
201    /// its presence is validated at `ports()` time (and by `fz doctor`),
202    /// mirroring the Cloudflare runtime.
203    fn provides(&self) -> Vec<Port> {
204        let mut provided = vec![
205            Port::Signer,
206            Port::HttpClient,
207            Port::Clock,
208            Port::IdGen,
209            Port::Defer,
210        ];
211        if self.db.is_some() {
212            provided.push(Port::Db);
213        }
214        if self.rate_limiter.is_some() {
215            provided.push(Port::RateLimiter);
216        }
217        if self.kv.is_some() {
218            provided.push(Port::KeyValue);
219        }
220        if self.blob.is_some() {
221            provided.push(Port::Blob);
222        }
223        if self.push.is_some() {
224            provided.push(Port::Push);
225        }
226        if self.payments.is_some() {
227            provided.push(Port::Payments);
228        }
229        if self.mailer.is_some() {
230            provided.push(Port::Mailer);
231        }
232        if self.captcha.is_some() {
233            provided.push(Port::Captcha);
234        }
235        provided
236    }
237}
238
239/// Snapshots a [`Ports`] bundle by cloning every `Arc` (core's `Ports`
240/// is not `Clone`; every field is a shared handle, so this is the whole
241/// job). Used to give the cron scheduler the same adapters the HTTP
242/// router serves with.
243pub(crate) fn clone_ports(ports: &Ports) -> Ports {
244    let mut snapshot = Ports::with_config(Arc::clone(&ports.config));
245    snapshot.db.clone_from(&ports.db);
246    snapshot.mailer.clone_from(&ports.mailer);
247    snapshot.captcha.clone_from(&ports.captcha);
248    snapshot.rate_limiter.clone_from(&ports.rate_limiter);
249    snapshot.signer.clone_from(&ports.signer);
250    snapshot.kv.clone_from(&ports.kv);
251    snapshot.blob.clone_from(&ports.blob);
252    snapshot.push.clone_from(&ports.push);
253    snapshot.payments.clone_from(&ports.payments);
254    snapshot.http.clone_from(&ports.http);
255    snapshot.clock.clone_from(&ports.clock);
256    snapshot.id_gen.clone_from(&ports.id_gen);
257    snapshot.defer.clone_from(&ports.defer);
258    snapshot
259}