Skip to content

Commit 176b278

Browse files
authored
Merge pull request #24 from 100monkeys-ai/feat-dev-server-hmr-17194680974801638956
Initialize dev server with HMR and file watching
2 parents bacc814 + b35ccd5 commit 176b278

6 files changed

Lines changed: 240 additions & 3 deletions

File tree

Cargo.lock

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

cli/src/commands/dev.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
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 {
@@ -16,6 +18,14 @@ pub struct DevArgs {
1618
pub async fn run(args: DevArgs) -> Result<()> {
1719
crate::output::info(&format!("Starting dev server on :{}", args.port));
1820
crate::output::info(&format!("Forge Studio on :{}", args.studio_port));
19-
forge_runtime::dev::dev_server::start_dev_server(args.port, args.studio_port).await?;
21+
22+
let config = DevServerConfig {
23+
port: args.port,
24+
studio_port: args.studio_port,
25+
project_root: Utf8PathBuf::from("."),
26+
};
27+
28+
start_dev_server(config).await?;
29+
2030
Ok(())
2131
}

runtime/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ futures = { workspace = true }
2727
notify = { workspace = true }
2828
uuid = { workspace = true }
2929
deno_core = { workspace = true }
30+
tokio-stream = { version = "0.1.18", features = ["sync"] }

runtime/src/dev/dev_server.rs

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,114 @@
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+
/// Port for the dev server (default: 3000)
19+
pub port: u16,
20+
/// Port for Forge Studio (default: 3001)
21+
pub studio_port: u16,
22+
/// The project root directory
23+
pub project_root: Utf8PathBuf,
24+
}
25+
26+
impl Default for DevServerConfig {
27+
fn default() -> Self {
28+
Self {
29+
port: 3000,
30+
studio_port: 3001,
31+
project_root: Utf8PathBuf::from("."),
32+
}
33+
}
34+
}
735

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

runtime/src/dev/hot_reload.rs

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

update_analyzer.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
cat << 'INNER_EOF' > /tmp/analyzer_diff.txt
2+
<<<<<<< SEARCH
3+
// process.env access (shimmable → Forge.env())
4+
Expression::StaticMemberExpression(member) => {
5+
if member.property.name == "env" {
6+
if let Expression::Identifier(obj) = &member.object {
7+
if obj.name == "process" {
8+
let line = line_number_at_offset(source, member.span.start);
9+
detected.push(DetectedApi {
10+
pattern: "process.env".into(),
11+
line,
12+
compatibility: Compatibility::Shimmable,
13+
});
14+
}
15+
}
16+
}
17+
// __dirname, __filename as member access targets are covered below
18+
}
19+
20+
// Standalone identifiers: __dirname, __filename, Buffer (as global)
21+
=======
22+
// process.env access (shimmable → Forge.env())
23+
Expression::StaticMemberExpression(member) if member.property.name == "env" => {
24+
if let Expression::Identifier(obj) = &member.object {
25+
if obj.name == "process" {
26+
let line = line_number_at_offset(source, member.span.start);
27+
detected.push(DetectedApi {
28+
pattern: "process.env".into(),
29+
line,
30+
compatibility: Compatibility::Shimmable,
31+
});
32+
}
33+
}
34+
// __dirname, __filename as member access targets are covered below
35+
}
36+
Expression::StaticMemberExpression(_) => {}
37+
38+
// Standalone identifiers: __dirname, __filename, Buffer (as global)
39+
>>>>>>> REPLACE
40+
INNER_EOF

0 commit comments

Comments
 (0)