//! The single runtime: the MCP backend and every app of `apps/` in one
//! process.
//!
//! Requests on `[gateway] listen` (`GATEWAY_LISTEN`) are routed by
//! `config/apps.toml` (`SLAVE_ROUTES` names another file, e.g.
//! `config/apps.fly.toml` in production), without a
//! proxy hop: apps call
//! MCP tools on the backend's router in-process. The backend also listens on
//! `APP_HOST:APP_PORT` for MCP clients (and separate apps), and apps without
//! `host`/`path` ("direct") on their own `port`. Apps with `embed = false`
//! run as processes of their own; their routes are proxied to them.
mod apps;
use std::process::ExitCode;
use axum::Router;
use slave_gateway::embed::Site;
use tokio::task::JoinSet;
use ::{{ crate_name }}::app;
use ::{{ crate_name }}::base::config::Config;
use ::{{ crate_name }}::base::state::AppState;
/// Where the routing table lives, relative to the project root, unless
/// `SLAVE_ROUTES` says otherwise.
const ROUTES: &str = "config/apps.toml";
/// Answers `ok` on every host, for the platform's health checks (which do
/// not send a routed `Host`).
const HEALTH_PATH: &str = "/_slave/health";
/// Collects the app routers for [`apps::mount`].
pub struct Mount {
routes: slave_gateway::Config,
backend: Router,
site: Option<Site>,
/// Apps reached on a port of their own: `(app, port, router)`.
direct: Vec<(String, u16, Router)>,
}
impl Mount {
/// Mounts app `name`, built by `make` from the backend's router and the
/// app's base path, wherever `config/apps.toml` lists it.
pub fn app(
&mut self,
name: &str,
make: fn(Router, &str) -> slave_framework::Result<Router>,
) -> slave_framework::Result<()> {
let listed = self.routes.domains.iter().any(|d| d.app == name);
let entries: Vec<_> = self
.routes
.domains
.iter()
.filter(|d| d.app == name && d.is_embedded())
.collect();
if entries.is_empty() {
if listed {
tracing::info!("app `{name}` runs separately (embed = false); proxied");
} else {
tracing::warn!("app `{name}` is not listed in {ROUTES}; not served");
}
return Ok(());
}
let base = entries
.iter()
.find_map(|d| d.path.clone())
.unwrap_or_default();
let router = make(self.backend.clone(), &base)?;
if entries.iter().any(|d| d.is_routed()) {
let site = self.site.take().expect("site is set while mounting");
self.site = Some(site.mount(name, router.clone()));
}
for entry in entries.iter().filter(|d| !d.is_routed()) {
if let Some(port) = entry.port {
self.direct.push((name.to_owned(), port, router.clone()));
}
}
Ok(())
}
}
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
ExitCode::FAILURE
}
}
}
async fn run() -> slave_framework::Result<()> {
slave_framework::env::load_dotenv()?;
slave_framework::logging::init()?;
let config = Config::from_env()?;
let db = ::{{ crate_name }}::database(&config.database).await?;
tracing::info!("Database: {}", config.database.describe());
let (host, port) = (config.host.clone(), config.port);
let backend = app::router(AppState::new(config, db.clone())?);
let routes_file = std::env::var("SLAVE_ROUTES").unwrap_or_else(|_| ROUTES.to_owned());
let routes = slave_gateway::Config::load(&routes_file).map_err(startup)?;
tracing::info!("Routes: {routes_file}");
// `SLAVE_TRUSTED_PROXY=1` when only a proxy that sets X-Forwarded-Proto
// reaches this process (Fly.io): apps then build https callback URLs.
let trusted = matches!(
std::env::var("SLAVE_TRUSTED_PROXY").as_deref(),
Ok("1" | "true")
);
let listen = std::env::var("GATEWAY_LISTEN").unwrap_or_else(|_| routes.gateway.listen.clone());
let site = Site::new(&routes)
.trust_forwarded(trusted)
.mount(slave_gateway::config::MCP_APP, backend.clone());
let mut mount = Mount {
routes,
backend: backend.clone(),
site: Some(site),
direct: Vec::new(),
};
apps::mount(&mut mount)?;
let site = mount.site.take().expect("site is set after mounting");
// Every app in config/apps.toml gets a row, owned by its `team` (set by
// `slave make:app`); a super admin can change it (`app_assign`).
let mut apps: Vec<(&str, Option<&str>)> = mount
.routes
.domains
.iter()
.filter(|d| !d.is_mcp())
.map(|d| (d.app.as_str(), d.team.as_deref()))
.collect();
apps.sort_unstable();
apps.dedup_by_key(|(name, _)| *name);
::{{ crate_name }}::modules::app::sync(&db, &apps)
.await
.map_err(startup)?;
for app in site.unmounted() {
tracing::warn!("{ROUTES} routes to `{app}`, which this runtime does not include");
}
let mut servers = JoinSet::new();
let (listen_host, listen_port) = split_address(&listen)?;
let site = site
.into_router()
.route(HEALTH_PATH, axum::routing::get(|| async { "ok" }));
servers.spawn(serve(site, listen_host, listen_port));
servers.spawn(serve(backend, host, port));
for (name, port, router) in mount.direct {
tracing::info!("{name}: direct on port {port}");
servers.spawn(serve(router, "127.0.0.1".to_owned(), port));
}
// Every server stops on Ctrl+C or SIGTERM; the first error stops the rest.
while let Some(finished) = servers.join_next().await {
match finished {
Ok(Ok(())) => {}
Ok(Err(err)) => return Err(err),
Err(err) => return Err(startup(err)),
}
}
Ok(())
}
async fn serve(router: Router, host: String, port: u16) -> slave_framework::Result<()> {
slave_framework::server::serve(router, &host, port).await
}
fn split_address(address: &str) -> slave_framework::Result<(String, u16)> {
let parsed = address.rsplit_once(':').and_then(|(host, port)| {
Some((host.trim_matches(['[', ']']).to_owned(), port.parse().ok()?))
});
parsed.ok_or_else(|| startup(format!("`{address}` must be host:port")))
}
fn startup(err: impl std::fmt::Display) -> slave_framework::Error {
slave_framework::Error::Server(std::io::Error::other(err.to_string()))
}