Skip to main content

boatramp_node/
handlers.rs

1//! WebAssembly handler-runtime assembly (moved from the binary — node-library N2b).
2//!
3//! Builds `boatramp_server::HandlerRuntime` from `[handlers]` config: the wasmtime
4//! engine plus the libsql `sql` binding (single-node file per site, a cluster sqld
5//! namespace, or an external Postgres/MySQL). Handlers-gated; a lean node gets a
6//! disabled runtime. Lives here (not the backend-agnostic `boatramp-server`)
7//! because it drives the concrete `boatramp-storage` SQL backends.
8
9#[cfg(feature = "handlers")]
10use crate::error::Error;
11use crate::error::Result;
12use boatramp_core::kv::KvStore;
13use std::path::Path;
14use std::sync::Arc;
15
16/// Build the WebAssembly handler runtime. With the `handlers` feature it wraps a
17/// wasmtime engine serving the kv/blob bindings from the server's own backends;
18/// otherwise it is an empty placeholder (handler routes fall through to static).
19#[cfg(feature = "handlers")]
20pub fn build_handler_runtime(
21    kv: Arc<dyn KvStore>,
22    storage: Arc<dyn boatramp_core::Storage>,
23    data_dir: &Path,
24    handlers_cfg: Option<&crate::config::HandlersConfig>,
25    messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
26    max_blob_bytes: u64,
27    max_component_bytes: u64,
28) -> Result<boatramp_server::HandlerRuntime> {
29    // Opt-in pooling allocator: faster instantiation, large
30    // up-front virtual reservation — benchmark before enabling.
31    let limits = boatramp_handlers::Limits::default();
32    let engine = if handlers_cfg.is_some_and(|h| h.pooling) {
33        boatramp_handlers::HandlerEngine::with_pooling(limits, 64)?
34    } else {
35        boatramp_handlers::HandlerEngine::new(limits, 64)?
36    };
37    let sql = build_sql_backends(handlers_cfg.and_then(|h| h.bindings.sql.as_ref()), data_dir)?;
38    // The `wasi:messaging` substrate: single-node `LogMessaging` over the same
39    // blob/KV backends by default, or the cluster coordinator when one is given.
40    let messaging: Arc<dyn boatramp_core::messaging::Messaging> = messaging_override
41        .unwrap_or_else(|| {
42            Arc::new(boatramp_core::messaging::LogMessaging::new(
43                storage.clone(),
44                kv.clone(),
45            ))
46        });
47    let runtime =
48        boatramp_server::HandlerRuntime::new(engine, kv, storage, Some(sql), Some(messaging));
49    // Apply the posture's host-side blob cap + component-size cap.
50    runtime.set_max_blob_bytes(max_blob_bytes);
51    runtime.set_max_component_bytes(max_component_bytes);
52    Ok(runtime)
53}
54
55/// Resolve the `[handlers.bindings.sql]` config to the libsql SQL backend.
56/// Single-node by default (an embedded file per site under `<data-dir>`); set
57/// `url` to bind a shared sqld cluster (a namespace per site). Either way sites
58/// get a real database boundary — see `boatramp_core::sql`.
59#[cfg(feature = "handlers")]
60fn build_sql_backends(
61    cfg: Option<&crate::config::SqlBindingConfig>,
62    data_dir: &Path,
63) -> Result<Arc<dyn boatramp_core::sql::SqlBackends>> {
64    let resolve_env = |var: &Option<String>| -> Result<Option<String>> {
65        match var {
66            Some(var) => Ok(Some(
67                std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?,
68            )),
69            None => Ok(None),
70        }
71    };
72
73    let backend = match cfg.and_then(|c| c.url.as_ref()) {
74        // Cluster: a sqld namespace per site. Auth tokens come from the
75        // environment, never the config file.
76        Some(url) => {
77            let cfg = cfg.expect("url implies cfg");
78            let admin_url = cfg.admin_url.as_ref().ok_or(Error::SqlAdminUrlRequired)?;
79            let token = resolve_env(&cfg.token_env)?.unwrap_or_default();
80            let admin_token = resolve_env(&cfg.admin_token_env)?;
81            let backends = boatramp_storage::LibsqlSqlBackends::remote(
82                url.clone(),
83                admin_url.clone(),
84                token,
85                admin_token,
86            );
87            // Optional read-replica routing: reads → replica, writes → primary.
88            match &cfg.replica_url {
89                Some(replica_url) => backends.with_read_replica(replica_url.clone()),
90                None => backends,
91            }
92        }
93        // Single-node: an embedded file per site.
94        None => {
95            let dir = cfg
96                .and_then(|c| c.dir.clone())
97                .unwrap_or_else(|| data_dir.join("handlers-sql"));
98            boatramp_storage::LibsqlSqlBackends::local(dir)
99        }
100    };
101    // Preview SQL policy (how preview deployments relate to live data).
102    let preview_mode = match cfg.and_then(|c| c.preview_mode.as_deref()) {
103        None | Some("empty") => boatramp_core::sql::PreviewSqlMode::Empty,
104        Some("branch") => boatramp_core::sql::PreviewSqlMode::Branch,
105        Some("shared") => boatramp_core::sql::PreviewSqlMode::Shared,
106        Some(other) => return Err(Error::UnknownPreviewMode(other.to_string())),
107    };
108    let preview_init = match cfg.and_then(|c| c.preview_init.as_ref()) {
109        Some(path) => {
110            Some(
111                std::fs::read_to_string(path).map_err(|err| Error::PreviewInitRead {
112                    path: path.clone(),
113                    source: err,
114                })?,
115            )
116        }
117        None => None,
118    };
119    let default: Arc<dyn boatramp_core::sql::SqlBackends> =
120        Arc::new(backend.with_preview_policy(preview_mode, preview_init));
121
122    // Overlay any external (bring-your-own) databases on the managed default.
123    // With none configured the default is returned unchanged (and a build
124    // without an external SQL engine never has to link the sqlx path).
125    let databases = cfg.map(|c| &c.databases);
126    if databases.is_none_or(std::collections::BTreeMap::is_empty) {
127        return Ok(default);
128    }
129    let databases = databases.expect("checked non-empty above");
130
131    #[cfg(any(feature = "sql-postgres", feature = "sql-mysql"))]
132    {
133        use boatramp_storage::sql_sqlx::{
134            connect, CompositeSqlBackends, ExternalSqlKind, ExternalSqlOptions,
135        };
136        let mut composite = CompositeSqlBackends::new(default);
137        for (name, db) in databases {
138            let kind = ExternalSqlKind::parse(&db.kind).ok_or_else(|| Error::SqlExternalKind {
139                name: name.clone(),
140                kind: db.kind.clone(),
141            })?;
142            if db.url_env.trim().is_empty() {
143                return Err(Error::SqlExternalUrlEnvMissing(name.clone()));
144            }
145            // The connection URL(s) are secrets, resolved from the environment.
146            let url =
147                std::env::var(&db.url_env).map_err(|_| Error::SqlEnvUnset(db.url_env.clone()))?;
148            let read_url = match &db.read_url_env {
149                Some(var) => Some(std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?),
150                None => None,
151            };
152            let opts = ExternalSqlOptions::new(url)
153                .with_read_url(read_url)
154                .with_max_connections(db.pool_max)
155                .read_only(db.read_only)
156                .with_connect_timeout(db.connect_timeout_secs.map(std::time::Duration::from_secs));
157            let external = connect(kind, &opts).map_err(|source| Error::SqlExternalConnect {
158                name: name.clone(),
159                source,
160            })?;
161            composite = composite.with_external(name.clone(), external, db.allow_preview);
162        }
163        Ok(Arc::new(composite))
164    }
165    #[cfg(not(any(feature = "sql-postgres", feature = "sql-mysql")))]
166    {
167        let name = databases.keys().next().cloned().unwrap_or_default();
168        Err(Error::SqlExternalUnavailable(name))
169    }
170}
171
172#[cfg(not(feature = "handlers"))]
173pub fn build_handler_runtime(
174    _kv: Arc<dyn KvStore>,
175    _storage: Arc<dyn boatramp_core::Storage>,
176    _data_dir: &Path,
177    _handlers_cfg: Option<&crate::config::HandlersConfig>,
178    _messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
179    _max_blob_bytes: u64,
180    _max_component_bytes: u64,
181) -> Result<boatramp_server::HandlerRuntime> {
182    Ok(boatramp_server::HandlerRuntime::disabled())
183}