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}