Skip to content

Commit bbdb352

Browse files
fix: address PR review comments for dev server initialization
- Wrapped `filter_map` closure in a future returning construct for tokio streams. - Explicitly handle `serde_json::to_string` serialization errors without returning empty payload strings. - Replaced the watcher `blocking_send` logic with non-blocking `try_send`. - Expanded watcher target directories to include `app/` and `public/` directories from the standard structure scaffold. - Bound server configuration host to `127.0.0.1` by default and added CLI argument. - Extracted `tokio-stream` to workspace global dependencies. Co-authored-by: Theaxiom <57013+Theaxiom@users.noreply.github.com>
1 parent 2a22816 commit bbdb352

7 files changed

Lines changed: 251 additions & 22 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ chrono = { version = "0.4", features = ["serde"] }
6868
# Utilities
6969
bytes = "1"
7070
futures = "0.3"
71+
tokio-stream = { version = "0.1.18", features = ["sync"] }
7172
async-trait = "0.1"
7273
dashmap = "6"
7374
indexmap = { version = "2", features = ["serde"] }

cli/src/commands/dev.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
//! `forge dev` — Start the development server and Forge Studio.
22
33
use anyhow::Result;
4+
use camino::Utf8PathBuf;
45
use clap::Args;
6+
use forge_runtime::dev::dev_server::{start_dev_server, DevServerConfig};
57

68
#[derive(Debug, Args)]
79
pub struct DevArgs {
10+
/// Host to bind to (default: 127.0.0.1)
11+
#[arg(long, default_value = "127.0.0.1")]
12+
pub host: String,
813
/// Port for the dev server (default: 3000)
914
#[arg(long, default_value = "3000")]
1015
pub port: u16,
@@ -14,8 +19,23 @@ pub struct DevArgs {
1419
}
1520

1621
pub async fn run(args: DevArgs) -> Result<()> {
17-
crate::output::info(&format!("Starting dev server on :{}", args.port));
18-
crate::output::info(&format!("Forge Studio on :{}", args.studio_port));
19-
// TODO: Delegate to forge-runtime dev server
22+
crate::output::info(&format!(
23+
"Starting dev server on {}:{}",
24+
args.host, args.port
25+
));
26+
crate::output::info(&format!(
27+
"Forge Studio on {}:{}",
28+
args.host, args.studio_port
29+
));
30+
31+
let config = DevServerConfig {
32+
host: args.host,
33+
port: args.port,
34+
studio_port: args.studio_port,
35+
project_root: Utf8PathBuf::from("."),
36+
};
37+
38+
start_dev_server(config).await?;
39+
2040
Ok(())
2141
}

foundry/client/src/migrate/analyzer.rs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -218,21 +218,20 @@ fn check_expression(expr: &Expression<'_>, source: &str, detected: &mut Vec<Dete
218218
}
219219

220220
// process.env access (shimmable → Forge.env())
221-
Expression::StaticMemberExpression(member) => {
222-
if member.property.name == "env" {
223-
if let Expression::Identifier(obj) = &member.object {
224-
if obj.name == "process" {
225-
let line = line_number_at_offset(source, member.span.start);
226-
detected.push(DetectedApi {
227-
pattern: "process.env".into(),
228-
line,
229-
compatibility: Compatibility::Shimmable,
230-
});
231-
}
221+
Expression::StaticMemberExpression(member) if member.property.name == "env" => {
222+
if let Expression::Identifier(obj) = &member.object {
223+
if obj.name == "process" {
224+
let line = line_number_at_offset(source, member.span.start);
225+
detected.push(DetectedApi {
226+
pattern: "process.env".into(),
227+
line,
228+
compatibility: Compatibility::Shimmable,
229+
});
232230
}
233231
}
234232
// __dirname, __filename as member access targets are covered below
235233
}
234+
Expression::StaticMemberExpression(_) => {}
236235

237236
// Standalone identifiers: __dirname, __filename, Buffer (as global)
238237
Expression::Identifier(ident) => {

runtime/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ anyhow = { workspace = true }
2323
tracing = { workspace = true }
2424
camino = { workspace = true }
2525
bytes = { workspace = true }
26-
futures = { workspace = true }
26+
futures.workspace = true
2727
notify = { workspace = true }
2828
uuid = { workspace = true }
2929
deno_core = { workspace = true }
30+
tokio-stream = { workspace = true }
31+
futures-util = "0.3.32"

runtime/src/dev/dev_server.rs

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,131 @@
33
//! Starts the dev server with HMR and Forge Studio.
44
//! Listens on port 3000 (app) and port 3001 (Studio) by default.
55
6+
use crate::dev::hot_reload::{hmr_router, HmrMessage, HmrState};
67
use crate::error::RuntimeError;
8+
use axum::Router;
9+
use camino::Utf8PathBuf;
10+
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
11+
use tokio::net::TcpListener;
12+
use tokio::sync::mpsc;
13+
use tracing::{error, info};
14+
15+
/// Configuration for the development server.
16+
#[derive(Debug, Clone)]
17+
pub struct DevServerConfig {
18+
/// Host to bind to (default: 127.0.0.1)
19+
pub host: String,
20+
/// Port for the dev server (default: 3000)
21+
pub port: u16,
22+
/// Port for Forge Studio (default: 3001)
23+
pub studio_port: u16,
24+
/// The project root directory
25+
pub project_root: Utf8PathBuf,
26+
}
27+
28+
impl Default for DevServerConfig {
29+
fn default() -> Self {
30+
Self {
31+
host: "127.0.0.1".to_string(),
32+
port: 3000,
33+
studio_port: 3001,
34+
project_root: Utf8PathBuf::from("."),
35+
}
36+
}
37+
}
738

839
/// Start the development server.
9-
pub async fn start_dev_server() -> Result<(), RuntimeError> {
10-
// TODO: Initialize file watcher, incremental compiler, HMR, and Studio
40+
pub async fn start_dev_server(config: DevServerConfig) -> Result<(), RuntimeError> {
41+
let hmr_state = HmrState::new();
42+
43+
// 1. Setup file watcher
44+
let (tx, mut rx) = mpsc::channel(100);
45+
46+
let mut watcher = RecommendedWatcher::new(
47+
move |res| {
48+
if let Err(e) = tx.try_send(res) {
49+
tracing::warn!("Failed to send watcher event: {}", e);
50+
}
51+
},
52+
Config::default(),
53+
)
54+
.map_err(|e| RuntimeError::Internal(format!("Failed to initialize watcher: {}", e)))?;
55+
56+
// Watch relevant directories
57+
let watch_dirs = ["app", "public", "src"];
58+
let mut watching_any = false;
59+
for dir in watch_dirs {
60+
let path = config.project_root.join(dir);
61+
if path.exists() {
62+
watcher
63+
.watch(path.as_std_path(), RecursiveMode::Recursive)
64+
.map_err(|e| {
65+
RuntimeError::Internal(format!("Failed to watch {} directory: {}", dir, e))
66+
})?;
67+
info!("Watching {} for changes", path);
68+
watching_any = true;
69+
}
70+
}
71+
72+
if !watching_any {
73+
error!(
74+
"No recognizable source directories (app, public, src) found in {}",
75+
config.project_root
76+
);
77+
}
78+
79+
// Spawn a background task to process file watcher events and trigger HMR
80+
let hmr_state_clone = hmr_state.clone();
81+
tokio::spawn(async move {
82+
while let Some(res) = rx.recv().await {
83+
match res {
84+
Ok(event) => {
85+
// For now, we broadcast a reload on any change.
86+
// Later, this will trigger the incremental compiler and only push updates for changed modules.
87+
if event.kind.is_modify() || event.kind.is_create() || event.kind.is_remove() {
88+
info!("File changed, triggering HMR reload");
89+
hmr_state_clone.broadcast(HmrMessage::Reload);
90+
}
91+
}
92+
Err(e) => error!("Watch error: {:?}", e),
93+
}
94+
}
95+
// Keep watcher alive by moving it into this task
96+
let _watcher = watcher;
97+
});
98+
99+
// 2. Main App Server (incorporates HMR router)
100+
// TODO: Combine with the actual app router
101+
let app = hmr_router(hmr_state.clone());
102+
let addr = format!("{}:{}", config.host, config.port);
103+
let listener = TcpListener::bind(&addr).await.map_err(RuntimeError::Io)?;
104+
105+
// 3. Studio Server
106+
let studio_app = Router::new().route(
107+
"/",
108+
axum::routing::get(|| async { "Forge Studio (Coming soon)" }),
109+
);
110+
let studio_addr = format!("{}:{}", config.host, config.studio_port);
111+
let studio_listener = TcpListener::bind(&studio_addr)
112+
.await
113+
.map_err(RuntimeError::Io)?;
114+
115+
info!("Dev server listening on {}", addr);
116+
info!("Forge Studio listening on {}", studio_addr);
117+
118+
// Run both servers concurrently
119+
tokio::try_join!(
120+
async {
121+
axum::serve(listener, app)
122+
.await
123+
.map_err(|e| RuntimeError::Http(e.to_string()))
124+
},
125+
async {
126+
axum::serve(studio_listener, studio_app)
127+
.await
128+
.map_err(|e| RuntimeError::Http(e.to_string()))
129+
}
130+
)?;
131+
11132
Ok(())
12133
}

runtime/src/dev/hot_reload.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,86 @@
55
//! 2. Pushes the new module source to connected browsers via a SSE stream
66
//! 3. The browser's HMR runtime replaces the module in-place if possible,
77
//! or triggers a full reload if the module graph changed structurally
8+
9+
use axum::{
10+
extract::State,
11+
response::sse::{Event, Sse},
12+
routing::get,
13+
Router,
14+
};
15+
use futures::stream::Stream;
16+
use std::convert::Infallible;
17+
use tokio::sync::broadcast;
18+
use tokio_stream::wrappers::BroadcastStream;
19+
20+
/// Message sent over the HMR broadcast channel.
21+
#[derive(Debug, Clone, serde::Serialize)]
22+
#[serde(tag = "type", content = "payload")]
23+
pub enum HmrMessage {
24+
/// A module was updated and should be re-evaluated.
25+
Update {
26+
/// The path of the module that was updated.
27+
path: String,
28+
/// The new module source code.
29+
code: String,
30+
},
31+
/// The application needs a full reload (e.g. structural change).
32+
Reload,
33+
}
34+
35+
/// State shared across HMR SSE connections.
36+
#[derive(Clone)]
37+
pub struct HmrState {
38+
/// Broadcast channel for pushing updates to connected clients.
39+
pub tx: broadcast::Sender<HmrMessage>,
40+
}
41+
42+
impl Default for HmrState {
43+
fn default() -> Self {
44+
Self::new()
45+
}
46+
}
47+
48+
impl HmrState {
49+
/// Create a new HmrState.
50+
pub fn new() -> Self {
51+
let (tx, _) = broadcast::channel(100);
52+
Self { tx }
53+
}
54+
55+
/// Broadcast an update to all connected clients.
56+
pub fn broadcast(&self, message: HmrMessage) {
57+
// Ignore send errors (happens when no clients are connected)
58+
let _ = self.tx.send(message);
59+
}
60+
}
61+
62+
/// Create the axum router for the HMR endpoint.
63+
pub fn hmr_router(state: HmrState) -> Router {
64+
Router::new()
65+
.route("/_forge/hmr", get(hmr_endpoint))
66+
.with_state(state)
67+
}
68+
69+
/// SSE endpoint handler for HMR connections.
70+
async fn hmr_endpoint(
71+
State(state): State<HmrState>,
72+
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
73+
let rx = state.tx.subscribe();
74+
let stream = futures::StreamExt::filter_map(BroadcastStream::new(rx), |msg| {
75+
let res = match msg {
76+
Ok(msg) => match serde_json::to_string(&msg) {
77+
Ok(json) => Some(Ok(Event::default().data(json))),
78+
Err(e) => {
79+
tracing::error!("Failed to serialize HMR message: {}", e);
80+
None
81+
}
82+
},
83+
// Ignore lag errors
84+
Err(_) => None,
85+
};
86+
std::future::ready(res)
87+
});
88+
89+
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::new())
90+
}

0 commit comments

Comments
 (0)