1use crate::config::ProjectConfig;
7
8fn to_pascal_case(s: &str) -> String {
10 s.split(|c| c == '-' || c == '_')
11 .map(|word| {
12 let mut chars = word.chars();
13 match chars.next() {
14 None => String::new(),
15 Some(first) => first.to_uppercase().chain(chars).collect(),
16 }
17 })
18 .collect()
19}
20
21pub fn cargo_toml(config: &ProjectConfig) -> String {
23 let ws = config.websocket_gateway.as_ref().unwrap();
24 let name = &config.name;
25
26 format!(
27 r#"[package]
28name = "{name}"
29version = "0.1.0"
30edition = "2021"
31rust-version = "1.75"
32description = "{display_name}"
33
34[dependencies]
35# AllFrame
36allframe-core = {{ version = "0.1", features = ["resilience", "otel"] }}
37
38# Web Framework & WebSocket
39axum = {{ version = "0.7", features = ["ws"] }}
40tokio-tungstenite = "0.26"
41tower = "0.5"
42tower-http = {{ version = "0.6", features = ["trace", "cors"] }}
43
44# Async
45tokio = {{ version = "1", features = ["full"] }}
46async-trait = "0.1"
47futures = "0.3"
48
49# Serialization
50serde = {{ version = "1.0", features = ["derive"] }}
51serde_json = "1.0"
52
53# Error handling
54thiserror = "2.0"
55anyhow = "1.0"
56
57# Tracing & Metrics
58tracing = "0.1"
59tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
60opentelemetry = {{ version = "0.27", features = ["metrics"] }}
61
62# Utilities
63chrono = {{ version = "0.4", features = ["serde"] }}
64uuid = {{ version = "1.0", features = ["v4", "serde"] }}
65dashmap = "6.0"
66dotenvy = "0.15"
67
68[dev-dependencies]
69tokio-test = "0.4"
70
71[[bin]]
72name = "{name}"
73path = "src/main.rs"
74"#,
75 name = name,
76 display_name = ws.display_name,
77 )
78}
79
80pub fn main_rs(config: &ProjectConfig) -> String {
82 let ws = config.websocket_gateway.as_ref().unwrap();
83 let pascal_name = to_pascal_case(&ws.service_name);
84
85 format!(
86 r#"//! {display_name}
87//!
88//! A WebSocket gateway service for real-time bidirectional communication.
89
90use std::sync::Arc;
91use tracing::info;
92
93mod config;
94mod error;
95mod domain;
96mod application;
97mod infrastructure;
98mod presentation;
99
100use config::Config;
101use application::{pascal_name}Hub;
102use infrastructure::HealthServer;
103
104#[tokio::main]
105async fn main() -> anyhow::Result<()> {{
106 // Load environment variables
107 dotenvy::dotenv().ok();
108
109 // Initialize tracing
110 tracing_subscriber::fmt()
111 .with_env_filter(
112 tracing_subscriber::EnvFilter::from_default_env()
113 .add_directive(tracing::Level::INFO.into()),
114 )
115 .init();
116
117 // Load configuration
118 let config = Config::from_env();
119 info!("Starting {display_name}");
120 info!("Configured channels: {{:?}}", config.channels.iter().map(|c| &c.name).collect::<Vec<_>>());
121
122 // Create WebSocket hub
123 let hub = Arc::new({pascal_name}Hub::new(config.clone()));
124
125 // Start hub background tasks
126 let hub_handle = {{
127 let hub = hub.clone();
128 tokio::spawn(async move {{
129 hub.run().await
130 }})
131 }};
132
133 // Start health server in background
134 let health_port = config.server.health_port;
135 let health_handle = tokio::spawn(async move {{
136 let health_server = HealthServer::new(health_port);
137 health_server.run().await
138 }});
139
140 // Create router and start WebSocket server
141 let app = presentation::create_router(hub.clone());
142
143 info!("Starting WebSocket server on port {{}}", config.server.http_port);
144 let listener = tokio::net::TcpListener::bind(
145 format!("0.0.0.0:{{}}", config.server.http_port)
146 ).await?;
147 axum::serve(listener, app).await?;
148
149 hub_handle.abort();
150 health_handle.abort();
151 info!("WebSocket gateway shutdown complete");
152 Ok(())
153}}
154"#,
155 pascal_name = pascal_name,
156 display_name = ws.display_name,
157 )
158}
159
160pub fn config_rs(config: &ProjectConfig) -> String {
162 let ws = config.websocket_gateway.as_ref().unwrap();
163
164 let channel_configs: Vec<String> = ws
165 .channels
166 .iter()
167 .map(|ch| {
168 format!(
169 r#" ChannelConfig {{
170 name: "{}".to_string(),
171 description: "{}".to_string(),
172 authenticated: {},
173 }}"#,
174 ch.name, ch.description, ch.authenticated
175 )
176 })
177 .collect();
178
179 format!(
180 r#"//! Service configuration
181
182use std::env;
183
184/// Main configuration
185#[derive(Debug, Clone)]
186pub struct Config {{
187 pub server: ServerConfig,
188 pub channels: Vec<ChannelConfig>,
189 pub max_connections_per_client: u32,
190 pub heartbeat_interval_secs: u64,
191 pub connection_timeout_secs: u64,
192}}
193
194/// Server configuration
195#[derive(Debug, Clone)]
196pub struct ServerConfig {{
197 pub http_port: u16,
198 pub health_port: u16,
199}}
200
201/// Channel configuration
202#[derive(Debug, Clone)]
203pub struct ChannelConfig {{
204 pub name: String,
205 pub description: String,
206 pub authenticated: bool,
207}}
208
209impl Config {{
210 pub fn from_env() -> Self {{
211 Self {{
212 server: ServerConfig {{
213 http_port: env::var("PORT")
214 .unwrap_or_else(|_| "{http_port}".to_string())
215 .parse()
216 .expect("PORT must be a number"),
217 health_port: env::var("HEALTH_PORT")
218 .unwrap_or_else(|_| "{health_port}".to_string())
219 .parse()
220 .expect("HEALTH_PORT must be a number"),
221 }},
222 channels: vec![
223{channel_configs}
224 ],
225 max_connections_per_client: env::var("MAX_CONNECTIONS_PER_CLIENT")
226 .unwrap_or_else(|_| "{max_connections}".to_string())
227 .parse()
228 .expect("MAX_CONNECTIONS_PER_CLIENT must be a number"),
229 heartbeat_interval_secs: env::var("HEARTBEAT_INTERVAL_SECS")
230 .unwrap_or_else(|_| "{heartbeat_interval}".to_string())
231 .parse()
232 .expect("HEARTBEAT_INTERVAL_SECS must be a number"),
233 connection_timeout_secs: env::var("CONNECTION_TIMEOUT_SECS")
234 .unwrap_or_else(|_| "{connection_timeout}".to_string())
235 .parse()
236 .expect("CONNECTION_TIMEOUT_SECS must be a number"),
237 }}
238 }}
239}}
240"#,
241 http_port = ws.server.http_port,
242 health_port = ws.server.health_port,
243 channel_configs = channel_configs.join(",\n"),
244 max_connections = ws.max_connections_per_client,
245 heartbeat_interval = ws.heartbeat_interval_secs,
246 connection_timeout = ws.connection_timeout_secs,
247 )
248}
249
250pub fn error_rs(config: &ProjectConfig) -> String {
252 let ws = config.websocket_gateway.as_ref().unwrap();
253 let pascal_name = to_pascal_case(&ws.service_name);
254
255 format!(
256 r#"//! Error types
257
258use thiserror::Error;
259
260/// WebSocket gateway errors
261#[derive(Error, Debug)]
262pub enum {pascal_name}Error {{
263 #[error("Connection error: {{0}}")]
264 Connection(String),
265
266 #[error("Channel not found: {{0}}")]
267 ChannelNotFound(String),
268
269 #[error("Authentication required")]
270 AuthRequired,
271
272 #[error("Connection limit exceeded")]
273 ConnectionLimitExceeded,
274
275 #[error("Message send error: {{0}}")]
276 SendError(String),
277
278 #[error("Internal error: {{0}}")]
279 Internal(String),
280}}
281"#,
282 pascal_name = pascal_name,
283 )
284}
285
286pub fn domain_mod(_config: &ProjectConfig) -> String {
288 r#"//! Domain layer
289
290pub mod messages;
291pub mod connection;
292
293pub use messages::*;
294pub use connection::*;
295"#
296 .to_string()
297}
298
299pub fn domain_messages(config: &ProjectConfig) -> String {
301 let ws = config.websocket_gateway.as_ref().unwrap();
302 let pascal_name = to_pascal_case(&ws.service_name);
303
304 format!(
305 r#"//! WebSocket message types
306
307use chrono::{{DateTime, Utc}};
308use serde::{{Deserialize, Serialize}};
309use uuid::Uuid;
310
311/// Client to server message
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(tag = "type", content = "payload")]
314pub enum ClientMessage {{
315 /// Subscribe to a channel
316 Subscribe {{ channel: String }},
317 /// Unsubscribe from a channel
318 Unsubscribe {{ channel: String }},
319 /// Send a message to a channel
320 Publish {{ channel: String, data: serde_json::Value }},
321 /// Ping for keepalive
322 Ping,
323}}
324
325/// Server to client message
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(tag = "type", content = "payload")]
328pub enum ServerMessage {{
329 /// Subscription confirmed
330 Subscribed {{ channel: String }},
331 /// Unsubscription confirmed
332 Unsubscribed {{ channel: String }},
333 /// Message from a channel
334 Message(ChannelMessage),
335 /// Pong response
336 Pong,
337 /// Error message
338 Error {{ code: String, message: String }},
339}}
340
341/// Channel message with metadata
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct ChannelMessage {{
344 pub id: Uuid,
345 pub channel: String,
346 pub data: serde_json::Value,
347 pub sender_id: Option<Uuid>,
348 pub timestamp: DateTime<Utc>,
349}}
350
351impl ChannelMessage {{
352 pub fn new(channel: String, data: serde_json::Value, sender_id: Option<Uuid>) -> Self {{
353 Self {{
354 id: Uuid::new_v4(),
355 channel,
356 data,
357 sender_id,
358 timestamp: Utc::now(),
359 }}
360 }}
361}}
362
363/// {pascal_name} event for internal use
364#[derive(Debug, Clone)]
365pub enum HubEvent {{
366 ClientConnected {{ client_id: Uuid }},
367 ClientDisconnected {{ client_id: Uuid }},
368 Subscribe {{ client_id: Uuid, channel: String }},
369 Unsubscribe {{ client_id: Uuid, channel: String }},
370 Broadcast {{ channel: String, message: ChannelMessage }},
371}}
372"#,
373 pascal_name = pascal_name,
374 )
375}
376
377pub fn domain_connection(_config: &ProjectConfig) -> String {
379 r#"//! Connection management
380
381use chrono::{DateTime, Utc};
382use uuid::Uuid;
383use std::collections::HashSet;
384
385/// Represents a connected client
386#[derive(Debug, Clone)]
387pub struct ClientConnection {
388 pub id: Uuid,
389 pub connected_at: DateTime<Utc>,
390 pub last_heartbeat: DateTime<Utc>,
391 pub subscriptions: HashSet<String>,
392 pub user_id: Option<Uuid>,
393}
394
395impl ClientConnection {
396 pub fn new() -> Self {
397 let now = Utc::now();
398 Self {
399 id: Uuid::new_v4(),
400 connected_at: now,
401 last_heartbeat: now,
402 subscriptions: HashSet::new(),
403 user_id: None,
404 }
405 }
406
407 pub fn with_user(mut self, user_id: Uuid) -> Self {
408 self.user_id = Some(user_id);
409 self
410 }
411
412 pub fn update_heartbeat(&mut self) {
413 self.last_heartbeat = Utc::now();
414 }
415
416 pub fn subscribe(&mut self, channel: &str) {
417 self.subscriptions.insert(channel.to_string());
418 }
419
420 pub fn unsubscribe(&mut self, channel: &str) {
421 self.subscriptions.remove(channel);
422 }
423
424 pub fn is_subscribed(&self, channel: &str) -> bool {
425 self.subscriptions.contains(channel)
426 }
427}
428
429impl Default for ClientConnection {
430 fn default() -> Self {
431 Self::new()
432 }
433}
434"#
435 .to_string()
436}
437
438pub fn application_mod(_config: &ProjectConfig) -> String {
440 r#"//! Application layer
441
442pub mod hub;
443
444pub use hub::*;
445"#
446 .to_string()
447}
448
449pub fn application_hub(config: &ProjectConfig) -> String {
451 let ws = config.websocket_gateway.as_ref().unwrap();
452 let pascal_name = to_pascal_case(&ws.service_name);
453
454 format!(
455 r#"//! WebSocket hub for managing connections and channels
456
457use std::sync::Arc;
458use dashmap::DashMap;
459use tokio::sync::{{broadcast, mpsc}};
460use tracing::{{info, warn, debug}};
461use uuid::Uuid;
462
463use crate::config::Config;
464use crate::domain::{{ClientConnection, ChannelMessage, HubEvent, ServerMessage}};
465use crate::error::{pascal_name}Error;
466
467/// Channel for sending messages to clients
468pub type ClientSender = mpsc::UnboundedSender<ServerMessage>;
469
470/// WebSocket hub managing all connections and channels
471pub struct {pascal_name}Hub {{
472 config: Config,
473 /// Connected clients
474 connections: DashMap<Uuid, ClientSender>,
475 /// Channel subscriptions: channel_name -> set of client_ids
476 subscriptions: DashMap<String, DashMap<Uuid, ()>>,
477 /// Broadcast channel for hub events
478 event_tx: broadcast::Sender<HubEvent>,
479}}
480
481impl {pascal_name}Hub {{
482 pub fn new(config: Config) -> Self {{
483 let (event_tx, _) = broadcast::channel(1024);
484
485 // Pre-create channels from config
486 let subscriptions = DashMap::new();
487 for channel in &config.channels {{
488 subscriptions.insert(channel.name.clone(), DashMap::new());
489 }}
490
491 Self {{
492 config,
493 connections: DashMap::new(),
494 subscriptions,
495 event_tx,
496 }}
497 }}
498
499 /// Run the hub event loop
500 pub async fn run(&self) {{
501 let mut rx = self.event_tx.subscribe();
502
503 loop {{
504 match rx.recv().await {{
505 Ok(event) => {{
506 self.handle_event(event).await;
507 }}
508 Err(broadcast::error::RecvError::Lagged(n)) => {{
509 warn!("Hub event receiver lagged by {{}} messages", n);
510 }}
511 Err(broadcast::error::RecvError::Closed) => {{
512 info!("Hub event channel closed");
513 break;
514 }}
515 }}
516 }}
517 }}
518
519 async fn handle_event(&self, event: HubEvent) {{
520 match event {{
521 HubEvent::ClientConnected {{ client_id }} => {{
522 debug!(client_id = %client_id, "Client connected");
523 }}
524 HubEvent::ClientDisconnected {{ client_id }} => {{
525 debug!(client_id = %client_id, "Client disconnected");
526 self.cleanup_client(client_id);
527 }}
528 HubEvent::Subscribe {{ client_id, channel }} => {{
529 if let Some(subs) = self.subscriptions.get(&channel) {{
530 subs.insert(client_id, ());
531 debug!(client_id = %client_id, channel = %channel, "Client subscribed");
532 }}
533 }}
534 HubEvent::Unsubscribe {{ client_id, channel }} => {{
535 if let Some(subs) = self.subscriptions.get(&channel) {{
536 subs.remove(&client_id);
537 debug!(client_id = %client_id, channel = %channel, "Client unsubscribed");
538 }}
539 }}
540 HubEvent::Broadcast {{ channel, message }} => {{
541 self.broadcast_to_channel(&channel, message).await;
542 }}
543 }}
544 }}
545
546 fn cleanup_client(&self, client_id: Uuid) {{
547 self.connections.remove(&client_id);
548 for entry in self.subscriptions.iter() {{
549 entry.value().remove(&client_id);
550 }}
551 }}
552
553 async fn broadcast_to_channel(&self, channel: &str, message: ChannelMessage) {{
554 if let Some(subscribers) = self.subscriptions.get(channel) {{
555 let msg = ServerMessage::Message(message);
556 for entry in subscribers.iter() {{
557 let client_id = *entry.key();
558 if let Some(sender) = self.connections.get(&client_id) {{
559 let _ = sender.send(msg.clone());
560 }}
561 }}
562 }}
563 }}
564
565 /// Register a new client connection
566 pub fn register_client(&self, client_id: Uuid, sender: ClientSender) {{
567 self.connections.insert(client_id, sender);
568 let _ = self.event_tx.send(HubEvent::ClientConnected {{ client_id }});
569 }}
570
571 /// Remove a client connection
572 pub fn unregister_client(&self, client_id: Uuid) {{
573 let _ = self.event_tx.send(HubEvent::ClientDisconnected {{ client_id }});
574 }}
575
576 /// Subscribe client to a channel
577 pub fn subscribe(&self, client_id: Uuid, channel: &str) -> Result<(), {pascal_name}Error> {{
578 if !self.subscriptions.contains_key(channel) {{
579 return Err({pascal_name}Error::ChannelNotFound(channel.to_string()));
580 }}
581
582 let _ = self.event_tx.send(HubEvent::Subscribe {{
583 client_id,
584 channel: channel.to_string(),
585 }});
586
587 // Send confirmation
588 if let Some(sender) = self.connections.get(&client_id) {{
589 let _ = sender.send(ServerMessage::Subscribed {{
590 channel: channel.to_string(),
591 }});
592 }}
593
594 Ok(())
595 }}
596
597 /// Unsubscribe client from a channel
598 pub fn unsubscribe(&self, client_id: Uuid, channel: &str) {{
599 let _ = self.event_tx.send(HubEvent::Unsubscribe {{
600 client_id,
601 channel: channel.to_string(),
602 }});
603
604 if let Some(sender) = self.connections.get(&client_id) {{
605 let _ = sender.send(ServerMessage::Unsubscribed {{
606 channel: channel.to_string(),
607 }});
608 }}
609 }}
610
611 /// Publish a message to a channel
612 pub fn publish(&self, channel: &str, data: serde_json::Value, sender_id: Option<Uuid>) -> Result<(), {pascal_name}Error> {{
613 if !self.subscriptions.contains_key(channel) {{
614 return Err({pascal_name}Error::ChannelNotFound(channel.to_string()));
615 }}
616
617 let message = ChannelMessage::new(channel.to_string(), data, sender_id);
618 let _ = self.event_tx.send(HubEvent::Broadcast {{
619 channel: channel.to_string(),
620 message,
621 }});
622
623 Ok(())
624 }}
625
626 /// Send pong response to a client
627 pub fn send_pong(&self, client_id: Uuid) {{
628 if let Some(sender) = self.connections.get(&client_id) {{
629 let _ = sender.send(ServerMessage::Pong);
630 }}
631 }}
632
633 /// Get the number of connected clients
634 pub fn connection_count(&self) -> usize {{
635 self.connections.len()
636 }}
637
638 /// Get available channels
639 pub fn channels(&self) -> Vec<String> {{
640 self.config.channels.iter().map(|c| c.name.clone()).collect()
641 }}
642}}
643"#,
644 pascal_name = pascal_name,
645 )
646}
647
648pub fn infrastructure_mod(_config: &ProjectConfig) -> String {
650 r#"//! Infrastructure layer
651
652pub mod health;
653
654pub use health::*;
655"#
656 .to_string()
657}
658
659pub fn infrastructure_health(_config: &ProjectConfig) -> String {
661 r#"//! Health check server
662
663use axum::{routing::get, Router};
664use tracing::info;
665
666/// Health check server for Kubernetes probes
667pub struct HealthServer {
668 port: u16,
669}
670
671impl HealthServer {
672 pub fn new(port: u16) -> Self {
673 Self { port }
674 }
675
676 pub async fn run(&self) -> Result<(), std::io::Error> {
677 let app = Router::new()
678 .route("/health", get(health))
679 .route("/ready", get(ready));
680
681 let addr = format!("0.0.0.0:{}", self.port);
682 info!("Health server listening on {}", addr);
683
684 let listener = tokio::net::TcpListener::bind(&addr).await?;
685 axum::serve(listener, app).await
686 }
687}
688
689async fn health() -> &'static str {
690 "OK"
691}
692
693async fn ready() -> &'static str {
694 "OK"
695}
696"#
697 .to_string()
698}
699
700pub fn presentation_mod(_config: &ProjectConfig) -> String {
702 r#"//! Presentation layer
703
704pub mod handlers;
705
706pub use handlers::*;
707"#
708 .to_string()
709}
710
711pub fn presentation_handlers(config: &ProjectConfig) -> String {
713 let ws = config.websocket_gateway.as_ref().unwrap();
714 let pascal_name = to_pascal_case(&ws.service_name);
715
716 format!(
717 r#"//! WebSocket handlers
718
719use std::sync::Arc;
720use axum::{{
721 extract::{{ws::{{Message, WebSocket, WebSocketUpgrade}}, State}},
722 response::IntoResponse,
723 routing::get,
724 Router,
725}};
726use futures::{{SinkExt, StreamExt}};
727use tokio::sync::mpsc;
728use tracing::{{error, info, debug}};
729use uuid::Uuid;
730
731use crate::application::{pascal_name}Hub;
732use crate::domain::{{ClientConnection, ClientMessage, ServerMessage}};
733
734type AppState = Arc<{pascal_name}Hub>;
735
736/// Create the WebSocket router
737pub fn create_router(hub: Arc<{pascal_name}Hub>) -> Router {{
738 Router::new()
739 .route("/ws", get(ws_handler))
740 .route("/channels", get(list_channels))
741 .route("/stats", get(stats))
742 .with_state(hub)
743}}
744
745async fn ws_handler(
746 ws: WebSocketUpgrade,
747 State(hub): State<AppState>,
748) -> impl IntoResponse {{
749 ws.on_upgrade(move |socket| handle_socket(socket, hub))
750}}
751
752async fn handle_socket(socket: WebSocket, hub: Arc<{pascal_name}Hub>) {{
753 let connection = ClientConnection::new();
754 let client_id = connection.id;
755
756 info!(client_id = %client_id, "WebSocket connection established");
757
758 let (mut ws_sender, mut ws_receiver) = socket.split();
759
760 // Create channel for sending messages to this client
761 let (tx, mut rx) = mpsc::unbounded_channel::<ServerMessage>();
762
763 // Register client with hub
764 hub.register_client(client_id, tx);
765
766 // Spawn task to forward messages from hub to WebSocket
767 let send_task = tokio::spawn(async move {{
768 while let Some(msg) = rx.recv().await {{
769 match serde_json::to_string(&msg) {{
770 Ok(text) => {{
771 if ws_sender.send(Message::Text(text.into())).await.is_err() {{
772 break;
773 }}
774 }}
775 Err(e) => {{
776 error!("Failed to serialize message: {{}}", e);
777 }}
778 }}
779 }}
780 }});
781
782 // Process incoming messages
783 while let Some(result) = ws_receiver.next().await {{
784 match result {{
785 Ok(Message::Text(text)) => {{
786 match serde_json::from_str::<ClientMessage>(&text) {{
787 Ok(msg) => handle_client_message(msg, client_id, &hub),
788 Err(e) => {{
789 debug!("Invalid message format: {{}}", e);
790 }}
791 }}
792 }}
793 Ok(Message::Close(_)) => {{
794 break;
795 }}
796 Ok(Message::Ping(_)) => {{
797 // Axum handles ping/pong automatically
798 }}
799 Ok(_) => {{
800 // Ignore other message types
801 }}
802 Err(e) => {{
803 error!(client_id = %client_id, error = %e, "WebSocket error");
804 break;
805 }}
806 }}
807 }}
808
809 // Cleanup
810 hub.unregister_client(client_id);
811 send_task.abort();
812
813 info!(client_id = %client_id, "WebSocket connection closed");
814}}
815
816fn handle_client_message(msg: ClientMessage, client_id: Uuid, hub: &{pascal_name}Hub) {{
817 match msg {{
818 ClientMessage::Subscribe {{ channel }} => {{
819 if let Err(e) = hub.subscribe(client_id, &channel) {{
820 error!(client_id = %client_id, error = %e, "Subscribe failed");
821 }}
822 }}
823 ClientMessage::Unsubscribe {{ channel }} => {{
824 hub.unsubscribe(client_id, &channel);
825 }}
826 ClientMessage::Publish {{ channel, data }} => {{
827 if let Err(e) = hub.publish(&channel, data, Some(client_id)) {{
828 error!(client_id = %client_id, error = %e, "Publish failed");
829 }}
830 }}
831 ClientMessage::Ping => {{
832 hub.send_pong(client_id);
833 }}
834 }}
835}}
836
837async fn list_channels(State(hub): State<AppState>) -> axum::Json<Vec<String>> {{
838 axum::Json(hub.channels())
839}}
840
841async fn stats(State(hub): State<AppState>) -> axum::Json<serde_json::Value> {{
842 axum::Json(serde_json::json!({{
843 "connections": hub.connection_count(),
844 "channels": hub.channels(),
845 }}))
846}}
847"#,
848 pascal_name = pascal_name,
849 )
850}
851
852pub fn readme(config: &ProjectConfig) -> String {
854 let ws = config.websocket_gateway.as_ref().unwrap();
855 let name = &config.name;
856
857 let channel_table: Vec<String> = ws
858 .channels
859 .iter()
860 .map(|ch| {
861 format!(
862 "| {} | {} | {} |",
863 ch.name,
864 ch.description,
865 if ch.authenticated { "Yes" } else { "No" }
866 )
867 })
868 .collect();
869
870 format!(
871 r#"# {display_name}
872
873A WebSocket gateway service built with AllFrame for real-time bidirectional communication.
874
875## Features
876
877- **Channel-based Messaging**: Subscribe/publish to named channels
878- **Connection Management**: Automatic heartbeat and timeout handling
879- **Scalable**: DashMap-based concurrent connection handling
880- **OpenTelemetry**: Distributed tracing and metrics
881- **Health Checks**: Kubernetes-ready liveness and readiness probes
882
883## Prerequisites
884
885- Rust 1.75+
886
887## Configuration
888
889Set the following environment variables:
890
891```bash
892# Server
893PORT=8080
894HEALTH_PORT=8081
895
896# WebSocket
897MAX_CONNECTIONS_PER_CLIENT=5
898HEARTBEAT_INTERVAL_SECS=30
899CONNECTION_TIMEOUT_SECS=60
900```
901
902## Channels
903
904| Name | Description | Auth Required |
905|------|-------------|---------------|
906{channel_table}
907
908## Running
909
910```bash
911# Development
912cargo run
913
914# Production
915cargo build --release
916./target/release/{name}
917```
918
919## WebSocket API
920
921### Connect
922
923```javascript
924const ws = new WebSocket('ws://localhost:8080/ws');
925```
926
927### Message Types
928
929#### Subscribe to Channel
930```json
931{{"type": "Subscribe", "payload": {{"channel": "events"}}}}
932```
933
934#### Unsubscribe from Channel
935```json
936{{"type": "Unsubscribe", "payload": {{"channel": "events"}}}}
937```
938
939#### Publish to Channel
940```json
941{{"type": "Publish", "payload": {{"channel": "events", "data": {{"key": "value"}}}}}}
942```
943
944#### Ping
945```json
946{{"type": "Ping"}}
947```
948
949### Server Responses
950
951```json
952// Subscription confirmed
953{{"type": "Subscribed", "payload": {{"channel": "events"}}}}
954
955// Message received
956{{"type": "Message", "payload": {{"id": "...", "channel": "events", "data": {{...}}, "timestamp": "..."}}}}
957
958// Pong
959{{"type": "Pong"}}
960
961// Error
962{{"type": "Error", "payload": {{"code": "CHANNEL_NOT_FOUND", "message": "..."}}}}
963```
964
965## REST Endpoints
966
967| Method | Endpoint | Description |
968|--------|----------|-------------|
969| GET | /ws | WebSocket upgrade endpoint |
970| GET | /channels | List available channels |
971| GET | /stats | Connection statistics |
972
973## Architecture
974
975```
976┌───────────────────────────────────────────────────────────┐
977│ WebSocket Gateway │
978│ ┌───────────┐ ┌──────────┐ ┌────────────────────┐ │
979│ │ Handler │───▶│ Hub │───▶│ Subscriptions │ │
980│ └───────────┘ └──────────┘ └────────────────────┘ │
981│ │ │ │ │
982│ ▼ ▼ ▼ │
983│ ┌───────────┐ ┌──────────┐ ┌────────────────────┐ │
984│ │ WebSocket │ │ Broadcast│ │ Connections │ │
985│ │ Upgrade │ │ Channel │ │ (DashMap) │ │
986│ └───────────┘ └──────────┘ └────────────────────┘ │
987└───────────────────────────────────────────────────────────┘
988```
989
990## License
991
992MIT
993"#,
994 display_name = ws.display_name,
995 name = name,
996 channel_table = channel_table.join("\n"),
997 )
998}
999
1000pub fn dockerfile(config: &ProjectConfig) -> String {
1002 let name = &config.name;
1003
1004 format!(
1005 r#"FROM rust:1.75-slim as builder
1006
1007WORKDIR /app
1008
1009# Install dependencies
1010RUN apt-get update && apt-get install -y \
1011 pkg-config \
1012 libssl-dev \
1013 && rm -rf /var/lib/apt/lists/*
1014
1015# Copy manifests
1016COPY Cargo.toml Cargo.lock ./
1017
1018# Create dummy main to cache dependencies
1019RUN mkdir src && echo "fn main() {{}}" > src/main.rs
1020RUN cargo build --release
1021RUN rm -rf src
1022
1023# Copy source
1024COPY src ./src
1025
1026# Build
1027RUN touch src/main.rs && cargo build --release
1028
1029# Runtime image
1030FROM debian:bookworm-slim
1031
1032RUN apt-get update && apt-get install -y \
1033 ca-certificates \
1034 libssl3 \
1035 && rm -rf /var/lib/apt/lists/*
1036
1037WORKDIR /app
1038
1039COPY --from=builder /app/target/release/{name} /app/
1040
1041ENV PORT=8080
1042ENV HEALTH_PORT=8081
1043EXPOSE 8080 8081
1044
1045CMD ["/app/{name}"]
1046"#,
1047 name = name,
1048 )
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use super::*;
1054
1055 #[test]
1056 fn test_to_pascal_case() {
1057 assert_eq!(to_pascal_case("ws_gateway"), "WsGateway");
1058 assert_eq!(to_pascal_case("real-time"), "RealTime");
1059 assert_eq!(to_pascal_case("simple"), "Simple");
1060 }
1061}