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, BoundedHttpClient, Captcha, Clock, Database, HarnessConfig, KeyValue, Mailer, Payments,
25    Port, Ports, Push, RateLimiter, Realtime, 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    realtime: Option<Arc<dyn Realtime>>,
59    mailer: Option<Arc<dyn Mailer>>,
60    captcha: Option<Arc<dyn Captcha>>,
61    /// Whether to assemble the `Push` port from `std::env` (issue #191).
62    /// Built lazily in `ports()` and memoised for the process, so a venture
63    /// may call `push_from_env()` before a tokio runtime exists.
64    #[cfg(feature = "push")]
65    push_from_env: bool,
66    #[cfg(feature = "push")]
67    assembled_push: Arc<std::sync::OnceLock<(Arc<dyn Push>, cratefield_push_wiring::PushWiring)>>,
68}
69
70impl Native {
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// The `Database` port: `Postgres` (`cratefield-adapter-postgres`) or
76    /// `SqliteDatabase` (`cratefield-adapter-sqlite`) — any `Database`
77    /// implementation.
78    #[must_use]
79    pub fn db(mut self, db: impl Database + 'static) -> Self {
80        self.db = Some(Arc::new(db));
81        self
82    }
83
84    /// `db` for an already-shared database.
85    #[must_use]
86    pub fn db_arc(mut self, db: Arc<dyn Database>) -> Self {
87        self.db = Some(db);
88        self
89    }
90
91    /// The `RateLimiter` port: [`RedisRateLimiter`](crate::RedisRateLimiter) in this crate, or a
92    /// test fake.
93    #[must_use]
94    pub fn rate_limiter(mut self, limiter: impl RateLimiter + 'static) -> Self {
95        self.rate_limiter = Some(Arc::new(limiter));
96        self
97    }
98
99    /// `rate_limiter` for an already-shared limiter.
100    #[must_use]
101    pub fn rate_limiter_arc(mut self, limiter: Arc<dyn RateLimiter>) -> Self {
102        self.rate_limiter = Some(limiter);
103        self
104    }
105
106    /// The `KeyValue` port: [`RedisKv`](crate::RedisKv) in this crate, or a test fake.
107    #[must_use]
108    pub fn kv(mut self, kv: impl KeyValue + 'static) -> Self {
109        self.kv = Some(Arc::new(kv));
110        self
111    }
112
113    /// `kv` for an already-shared store.
114    #[must_use]
115    pub fn kv_arc(mut self, kv: Arc<dyn KeyValue>) -> Self {
116        self.kv = Some(kv);
117        self
118    }
119
120    /// The `Blob` port: a directory store ([`crate::DirBlob`]) or an
121    /// S3-compatible adapter.
122    #[must_use]
123    pub fn blob_arc(mut self, blob: Arc<dyn Blob>) -> Self {
124        self.blob = Some(blob);
125        self
126    }
127
128    /// The `Push` port: an APNs adapter (`cratefield-adapter-apns`) or a test
129    /// fake. Notifications reach a device over the runtime's `HttpClient`.
130    #[must_use]
131    pub fn push_arc(mut self, push: Arc<dyn Push>) -> Self {
132        self.push = Some(push);
133        self
134    }
135
136    /// Assembles the `Push` port from the process environment instead of
137    /// taking an adapter (issue #191): one `RoutingPush` over whichever of
138    /// APNs, FCM and Web Push the deployment configured, built by the same
139    /// `cratefield_push_wiring::build_push` the Workers runtime, `fz push`
140    /// and `fz doctor` call, so the four cannot drift apart on a variable
141    /// name.
142    ///
143    /// The port is then always provided, even with nothing configured: the
144    /// router answers `NotConfigured` for every recipient, exactly as an
145    /// unconfigured adapter does. `serve_on` logs the resulting
146    /// [`PushWiring`](cratefield_push_wiring::PushWiring) report once at cold
147    /// start, and an explicit `.push_arc(..)` still wins.
148    ///
149    /// # This is a declaration, and `fz doctor` is what checks it
150    ///
151    /// Declaring the port rather than gating it on what `std::env` holds at
152    /// build time is deliberate, and it keeps this runtime's answer the same
153    /// as the Workers one's: there a Worker's `Env` does not exist when
154    /// `Harness::build` runs, so a venture that built here and refused there
155    /// (or the reverse) is the divergence issue #143 was about.
156    ///
157    /// `fz doctor` is the gate that used to be the build's. It runs where
158    /// the deployment's environment is known, and refuses a production
159    /// venture whose modules require push and whose environment routes no
160    /// transport at all (issue #191).
161    #[cfg(feature = "push")]
162    #[must_use]
163    pub fn push_from_env(mut self) -> Self {
164        self.push_from_env = true;
165        self
166    }
167
168    /// The environment-assembled `Push` port and its report, built once per
169    /// process.
170    ///
171    /// `None` when the venture did not ask for it — **and** when it passed
172    /// an explicit adapter, which wins: assembling a stack of adapters
173    /// nothing will serve with costs a `.p8` parse, an RSA parse and a VAPID
174    /// scalar, and then reports on transports the venture deliberately
175    /// overrode.
176    #[cfg(feature = "push")]
177    fn env_push(&self) -> Option<&(Arc<dyn Push>, cratefield_push_wiring::PushWiring)> {
178        if !self.push_from_env || self.push.is_some() {
179            return None;
180        }
181        Some(self.assembled_push.get_or_init(|| {
182            let clock: Arc<dyn Clock> = Arc::new(TokioClock);
183            let http: Arc<dyn cratefield_core::HttpClient> = Arc::new(BoundedHttpClient::new(
184                Arc::new(ReqwestClient::new()),
185                Arc::clone(&clock),
186            ));
187            cratefield_push_wiring::build_push(&EnvConfig, &http, &clock)
188        }))
189    }
190
191    /// Which push transports this deployment configured. `serve_on` logs
192    /// this at cold start; nothing else needs it.
193    ///
194    /// `None` when the venture did not call
195    /// [`push_from_env`](Self::push_from_env), or passed an explicit adapter
196    /// that wins over it — there is nothing to report about an environment
197    /// nothing reads.
198    ///
199    /// Call it *after* `ports()`, as `serve_on` does: `ports()` has then
200    /// already assembled and memoised the adapters, and this is the getter
201    /// it looks like.
202    #[cfg(feature = "push")]
203    #[must_use]
204    pub fn push_wiring(&self) -> Option<&cratefield_push_wiring::PushWiring> {
205        self.env_push().map(|(_, wiring)| wiring)
206    }
207
208    /// The `Payments` port: a Stripe adapter (`cratefield-adapter-stripe`) or a
209    /// test fake, over the runtime's `HttpClient`.
210    #[must_use]
211    pub fn payments_arc(mut self, payments: Arc<dyn Payments>) -> Self {
212        self.payments = Some(payments);
213        self
214    }
215
216    /// The `Realtime` port: the in-process room registry
217    /// ([`crate::InProcessRealtime`]) built from the module's `RoomHandler`.
218    #[must_use]
219    pub fn realtime_arc(mut self, realtime: Arc<dyn Realtime>) -> Self {
220        self.realtime = Some(realtime);
221        self
222    }
223
224    #[must_use]
225    pub fn mailer(mut self, mailer: impl Mailer + 'static) -> Self {
226        self.mailer = Some(Arc::new(mailer));
227        self
228    }
229
230    #[must_use]
231    pub fn mailer_arc(mut self, mailer: Arc<dyn Mailer>) -> Self {
232        self.mailer = Some(mailer);
233        self
234    }
235
236    #[must_use]
237    pub fn captcha(mut self, captcha: impl Captcha + 'static) -> Self {
238        self.captcha = Some(Arc::new(captcha));
239        self
240    }
241
242    #[must_use]
243    pub fn captcha_arc(mut self, captcha: Arc<dyn Captcha>) -> Self {
244        self.captcha = Some(captcha);
245        self
246    }
247
248    /// Resolves the port bundle once per process from the builder and
249    /// `std::env`: config, `Signer` (from `HARNESS_SECRET` — warn once
250    /// and leave unset when missing, exactly like the Cloudflare
251    /// runtime), `HttpClient` over reqwest, `Clock` over the system
252    /// clock, `IdGen`, and `Defer` over `tokio::spawn`.
253    ///
254    /// Unlike the Workers runtime — which resolves bindings per event —
255    /// a native process builds one bundle and shares it: every adapter
256    /// here is an `Arc`, so `clone_ports` snapshots cheaply.
257    #[must_use]
258    pub fn ports(&self) -> Ports {
259        let config: Arc<dyn cratefield_core::Config> = Arc::new(EnvConfig);
260        let mut ports = Ports::with_config(Arc::clone(&config));
261
262        ports.db.clone_from(&self.db);
263        ports.rate_limiter.clone_from(&self.rate_limiter);
264        ports.kv.clone_from(&self.kv);
265        ports.blob.clone_from(&self.blob);
266        ports.push.clone_from(&self.push);
267        ports.payments.clone_from(&self.payments);
268        ports.realtime.clone_from(&self.realtime);
269        ports.mailer.clone_from(&self.mailer);
270        ports.captcha.clone_from(&self.captcha);
271
272        // An explicit adapter wins; otherwise assemble one from the
273        // environment when the venture asked for it (issue #191).
274        #[cfg(feature = "push")]
275        if ports.push.is_none()
276            && let Some((push, _)) = self.env_push()
277        {
278            ports.push = Some(Arc::clone(push));
279        }
280
281        match HarnessConfig::from_config(&*config) {
282            Ok(parsed) => {
283                // Pseudonymise logged emails with a key derived from the
284                // harness secret, not a bare (reversible) hash — issue #135.
285                cratefield_core::set_log_pseudonym_key(parsed.harness_secret.as_bytes());
286                ports.signer = Some(Arc::new(parsed.signer()));
287            }
288            Err(_) => warn_once(
289                &WARNED_SIGNER,
290                "HARNESS_SECRET missing or invalid: Signer port not provided",
291            ),
292        }
293
294        let clock: Arc<dyn Clock> = Arc::new(TokioClock);
295        ports.clock = Some(Arc::clone(&clock));
296        ports.http = Some(Arc::new(BoundedHttpClient::new(
297            Arc::new(ReqwestClient::new()),
298            clock,
299        )));
300        ports.id_gen = Some(Arc::new(UlidIdGen));
301        ports.defer = Some(Arc::new(SpawnDefer));
302        ports
303    }
304}
305
306impl Runtime for Native {
307    /// The static set used by `Harness::build`. `Signer` is included:
308    /// its presence is validated at `ports()` time (and by `fz doctor`),
309    /// mirroring the Cloudflare runtime.
310    fn provides(&self) -> Vec<Port> {
311        let mut provided = vec![
312            Port::Signer,
313            Port::HttpClient,
314            Port::Clock,
315            Port::IdGen,
316            Port::Defer,
317        ];
318        if self.db.is_some() {
319            provided.push(Port::Db);
320        }
321        if self.rate_limiter.is_some() {
322            provided.push(Port::RateLimiter);
323        }
324        if self.kv.is_some() {
325            provided.push(Port::KeyValue);
326        }
327        if self.blob.is_some() {
328            provided.push(Port::Blob);
329        }
330        // `push_from_env` provides the port whatever the environment holds:
331        // with nothing configured the router answers `NotConfigured` for
332        // every recipient, which is a provided port that sends nothing, not
333        // an absent one (issue #191).
334        //
335        // `std::env` is readable here, unlike on Workers — and consulting it
336        // is still the wrong move: `provides()` would then answer one thing
337        // in CI and another on the box, and a venture would build here and
338        // refuse to build on its Workers twin, which is the divergence
339        // issue #143 was about. `fz doctor` carries the refusal instead, on
340        // both runtimes, where the deployment's environment is known.
341        #[cfg(feature = "push")]
342        let push_provided = self.push.is_some() || self.push_from_env;
343        #[cfg(not(feature = "push"))]
344        let push_provided = self.push.is_some();
345        if push_provided {
346            provided.push(Port::Push);
347        }
348        if self.payments.is_some() {
349            provided.push(Port::Payments);
350        }
351        if self.realtime.is_some() {
352            provided.push(Port::Realtime);
353        }
354        if self.mailer.is_some() {
355            provided.push(Port::Mailer);
356        }
357        if self.captcha.is_some() {
358            provided.push(Port::Captcha);
359        }
360        provided
361    }
362
363    /// The same production gate the Cloudflare runtime applies
364    /// (issue #133), which this runtime never did (issue #143): a
365    /// Turnstile adapter that is not hostname-bound, or left fail-open,
366    /// cannot protect a production `HumanForm` route, so the port is
367    /// present but not usable. Without this a self-hosted venture booted
368    /// past a check its Workers twin refuses. Adapters that do not report
369    /// (`binding() == None`) count as effective when present: presence is
370    /// all the runtime can know about them.
371    fn effectively_configured(&self, port: Port) -> bool {
372        match port {
373            Port::Captcha => self.captcha.as_ref().is_some_and(|captcha| {
374                captcha
375                    .binding()
376                    .is_none_or(|binding| binding.hostname_bound && !binding.fail_open)
377            }),
378            _ => self.provides().contains(&port),
379        }
380    }
381}
382
383/// Snapshots a [`Ports`] bundle by cloning every `Arc` (core's `Ports`
384/// is not `Clone`; every field is a shared handle, so this is the whole
385/// job). Used to give the cron scheduler the same adapters the HTTP
386/// router serves with.
387pub(crate) fn clone_ports(ports: &Ports) -> Ports {
388    let mut snapshot = Ports::with_config(Arc::clone(&ports.config));
389    snapshot.db.clone_from(&ports.db);
390    snapshot.mailer.clone_from(&ports.mailer);
391    snapshot.captcha.clone_from(&ports.captcha);
392    snapshot.rate_limiter.clone_from(&ports.rate_limiter);
393    snapshot.signer.clone_from(&ports.signer);
394    snapshot.kv.clone_from(&ports.kv);
395    snapshot.blob.clone_from(&ports.blob);
396    snapshot.push.clone_from(&ports.push);
397    snapshot.payments.clone_from(&ports.payments);
398    snapshot.realtime.clone_from(&ports.realtime);
399    snapshot.http.clone_from(&ports.http);
400    snapshot.clock.clone_from(&ports.clock);
401    snapshot.id_gen.clone_from(&ports.id_gen);
402    snapshot.defer.clone_from(&ports.defer);
403    snapshot
404}