Unify webhook servers into single WebhookServer

Replace the dual-server architecture (HttpChannel + WasmChannelServer both
competing for port 8080) with a single WebhookServer that composes route
fragments from all sources. Channels define routes but never spawn servers.

- Add WebhookServer struct that collects Router fragments and binds one listener
- Extract routes() from HttpChannel, remove server-spawning from start/shutdown
- Delete WasmChannelServer (keep WasmChannelRouter and route builder)
- Rewire main.rs to compose all webhook routes into one server

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-06 11:12:17 -08:00
co-authored by Claude Opus 4.6
parent 8439293df3
commit 9d156411fc
7 changed files with 174 additions and 165 deletions
+23 -59
View File
@@ -1,6 +1,5 @@
//! HTTP webhook channel for receiving messages via HTTP POST.
use std::net::SocketAddr;
use std::sync::Arc;
use async_trait::async_trait;
@@ -32,8 +31,6 @@ struct HttpChannelState {
tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>,
/// Pending responses keyed by message ID.
pending_responses: RwLock<std::collections::HashMap<Uuid, oneshot::Sender<String>>>,
/// Server shutdown signal.
shutdown_tx: RwLock<Option<oneshot::Sender<()>>>,
/// Expected webhook secret for authentication (if configured).
webhook_secret: Option<String>,
/// Fixed user ID for this HTTP channel.
@@ -74,7 +71,6 @@ impl HttpChannel {
state: Arc::new(HttpChannelState {
tx: RwLock::new(None),
pending_responses: RwLock::new(std::collections::HashMap::new()),
shutdown_tx: RwLock::new(None),
webhook_secret,
user_id,
rate_limit: tokio::sync::Mutex::new(RateLimitState {
@@ -84,6 +80,24 @@ impl HttpChannel {
}),
}
}
/// Return the channel's axum routes with state applied.
///
/// The returned `Router` shares the same `Arc<HttpChannelState>` that
/// `start()` later populates. Before `start()` is called the webhook
/// handler returns 503 ("Channel not started").
pub fn routes(&self) -> Router {
Router::new()
.route("/health", get(health_handler))
.route("/webhook", post(webhook_handler))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(self.state.clone())
}
/// Return the configured host and port for this channel.
pub fn addr(&self) -> (&str, u16) {
(&self.config.host, self.config.port)
}
}
#[derive(Debug, Deserialize)]
@@ -303,53 +317,11 @@ impl Channel for HttpChannel {
let (tx, rx) = mpsc::channel(256);
*self.state.tx.write().await = Some(tx);
let state = self.state.clone();
let host = self.config.host.clone();
let port = self.config.port;
// Parse address before spawning so we can return errors
let addr: SocketAddr =
format!("{}:{}", host, port)
.parse()
.map_err(|e| ChannelError::StartupFailed {
name: "http".to_string(),
reason: format!("Invalid address '{}:{}': {}", host, port, e),
})?;
// Bind listener before spawning so we can return errors
let listener =
tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| ChannelError::StartupFailed {
name: "http".to_string(),
reason: format!("Failed to bind to {}: {}", addr, e),
})?;
tracing::info!("HTTP channel listening on {}", addr);
// Create router
let app = Router::new()
.route("/health", get(health_handler))
.route("/webhook", post(webhook_handler))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state.clone());
// Create shutdown channel
let (shutdown_tx, shutdown_rx) = oneshot::channel();
*self.state.shutdown_tx.write().await = Some(shutdown_tx);
// Spawn server (listener is already bound, serve errors are logged)
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::info!("HTTP channel shutting down");
})
.await
{
tracing::error!("HTTP server error: {}", e);
}
});
tracing::info!(
"HTTP channel ready ({}:{})",
self.config.host,
self.config.port
);
Ok(Box::pin(ReceiverStream::new(rx)))
}
@@ -363,13 +335,10 @@ impl Channel for HttpChannel {
if let Some(tx) = self.state.pending_responses.write().await.remove(&msg.id) {
let _ = tx.send(response.content);
}
// For async webhooks, we'd need to make an HTTP callback here
// but that requires the caller to provide a callback URL
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
// Check if we have an active sender
if self.state.tx.read().await.is_some() {
Ok(())
} else {
@@ -380,11 +349,6 @@ impl Channel for HttpChannel {
}
async fn shutdown(&self) -> Result<(), ChannelError> {
// Send shutdown signal
if let Some(tx) = self.state.shutdown_tx.write().await.take() {
let _ = tx.send(());
}
// Clear the message sender
*self.state.tx.write().await = None;
Ok(())
}
+2
View File
@@ -32,8 +32,10 @@ mod http;
mod manager;
mod repl;
pub mod wasm;
mod webhook_server;
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
pub use http::HttpChannel;
pub use manager::ChannelManager;
pub use repl::ReplChannel;
pub use webhook_server::{WebhookServer, WebhookServerConfig};
+1 -3
View File
@@ -95,9 +95,7 @@ pub use loader::{
DiscoveredChannel, LoadResults, LoadedChannel, WasmChannelLoader, default_channels_dir,
discover_channels,
};
pub use router::{
RegisteredEndpoint, WasmChannelRouter, WasmChannelServer, create_wasm_channel_router,
};
pub use router::{RegisteredEndpoint, WasmChannelRouter, create_wasm_channel_router};
pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig};
pub use schema::{
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
-51
View File
@@ -4,7 +4,6 @@
//! registered paths. Handles secret validation at the host level.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
@@ -469,56 +468,6 @@ pub fn create_wasm_channel_router(
.with_state(state)
}
/// HTTP server for WASM channel webhooks.
pub struct WasmChannelServer {
router: Arc<WasmChannelRouter>,
extension_manager: Option<Arc<crate::extensions::ExtensionManager>>,
}
impl WasmChannelServer {
/// Create a new server.
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
Self {
router,
extension_manager: None,
}
}
/// Set the extension manager for OAuth callback handling.
pub fn with_extension_manager(
mut self,
manager: Arc<crate::extensions::ExtensionManager>,
) -> Self {
self.extension_manager = Some(manager);
self
}
/// Start the HTTP server.
///
/// Returns a handle that can be used to shut down the server.
pub async fn start(
&self,
addr: SocketAddr,
) -> Result<tokio::task::JoinHandle<()>, std::io::Error> {
let app = create_wasm_channel_router(self.router.clone(), self.extension_manager.clone());
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(
addr = %addr,
"WASM channel HTTP server started"
);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
tracing::error!("WASM channel HTTP server error: {}", e);
}
});
Ok(handle)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
+92
View File
@@ -0,0 +1,92 @@
//! Unified HTTP server for all webhook routes.
//!
//! Composes route fragments from HttpChannel, WASM channel router, etc.
//! into a single axum server. Channels define routes but never spawn servers.
use std::net::SocketAddr;
use axum::Router;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use crate::error::ChannelError;
/// Configuration for the unified webhook server.
pub struct WebhookServerConfig {
/// Address to bind the server to.
pub addr: SocketAddr,
}
/// A single HTTP server that hosts all webhook routes.
///
/// Channels contribute route fragments via `add_routes()`, then a single
/// `start()` call binds the listener and spawns the server task.
pub struct WebhookServer {
config: WebhookServerConfig,
routes: Vec<Router>,
shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>,
}
impl WebhookServer {
/// Create a new webhook server with the given bind address.
pub fn new(config: WebhookServerConfig) -> Self {
Self {
config,
routes: Vec::new(),
shutdown_tx: None,
handle: None,
}
}
/// Accumulate a route fragment. Each fragment should already have its
/// state applied via `.with_state()`.
pub fn add_routes(&mut self, router: Router) {
self.routes.push(router);
}
/// Bind the listener, merge all route fragments, and spawn the server.
pub async fn start(&mut self) -> Result<(), ChannelError> {
let mut app = Router::new();
for fragment in self.routes.drain(..) {
app = app.merge(fragment);
}
let listener = tokio::net::TcpListener::bind(self.config.addr)
.await
.map_err(|e| ChannelError::StartupFailed {
name: "webhook_server".to_string(),
reason: format!("Failed to bind to {}: {}", self.config.addr, e),
})?;
tracing::info!("Webhook server listening on {}", self.config.addr);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
self.shutdown_tx = Some(shutdown_tx);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::info!("Webhook server shutting down");
})
.await
{
tracing::error!("Webhook server error: {}", e);
}
});
self.handle = Some(handle);
Ok(())
}
/// Signal graceful shutdown and wait for the server task to finish.
pub async fn shutdown(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.handle.take() {
let _ = handle.await;
}
}
}