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