Rust library to generate static websites
1pub(crate) mod server;
2
3mod build;
4mod filterer;
5
6use notify::{
7 EventKind, RecursiveMode,
8 event::{CreateKind, ModifyKind, RemoveKind},
9};
10use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer};
11use quanta::Instant;
12use server::WebSocketMessage;
13use std::{fs, path::Path};
14use tokio::{
15 signal,
16 sync::{broadcast, mpsc::channel},
17 task::JoinHandle,
18};
19use tracing::{error, info};
20
21use crate::dev::build::BuildManager;
22
23pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> {
24 let start_time = Instant::now();
25 info!(name: "dev", "Preparing dev environment…");
26
27 let (sender_websocket, _) = broadcast::channel::<WebSocketMessage>(100);
28
29 // Create build manager (it will create its own status state internally)
30 let build_manager = BuildManager::new(sender_websocket.clone());
31
32 // Do initial build
33 info!(name: "build", "Doing initial build…");
34 let initial_build_success = build_manager.do_initial_build().await?;
35
36 // Set up file watching with debouncer
37 let (tx, mut rx) = channel::<DebounceEventResult>(1000);
38
39 let directories = fs::read_dir(cwd)?
40 .filter_map(|entry| entry.ok())
41 .filter(|entry| entry.path().is_dir())
42 .filter(|entry| {
43 let path = entry.path();
44 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
45 !matches!(file_name, "target" | ".git" | "dist")
46 })
47 .map(|entry| entry.path())
48 .collect::<Vec<_>>();
49
50 let mut debouncer = new_debouncer(
51 std::time::Duration::from_millis(100),
52 None,
53 move |result: DebounceEventResult| {
54 tx.blocking_send(result).unwrap_or(());
55 },
56 )?;
57
58 // Watch the root directly both for changes to files like Cargo.toml and for new directories
59 debouncer.watch(cwd, RecursiveMode::NonRecursive)?;
60
61 // It'd seems like it'd be much easier to just watch recursively from cwd, but the problem is that this ends up
62 // watching a looooooot of files that we don't want to watch (target directory, dist directory, .git directory, etc.) which is causing about a million issues in Notify.
63 // The fork of Notify we use has support for filtering while watching, but it doesn't seem to work super well in practice.
64 // So instead we just watch the top-level directories (excluding known ones to ignore) and then add/remove watches for new/deleted directories as needed.
65 for dir in &directories {
66 debouncer.watch(dir, RecursiveMode::Recursive)?;
67 }
68
69 let mut web_server_thread: Option<tokio::task::JoinHandle<()>> = None;
70
71 // If initial build succeeded, start web server immediately
72 if initial_build_success {
73 info!(name: "dev", "Starting web server...");
74 web_server_thread = Some(tokio::spawn(server::start_dev_web_server(
75 start_time,
76 sender_websocket.clone(),
77 host,
78 port,
79 None,
80 build_manager.current_status(),
81 )));
82 }
83
84 // Clone build manager for the file watcher task
85 let build_manager_watcher = build_manager.clone();
86 let sender_websocket_watcher = sender_websocket.clone();
87
88 let file_watcher_task = tokio::spawn(async move {
89 let mut dev_server_started = initial_build_success;
90 let mut dev_server_handle: Option<JoinHandle<()>> = None;
91
92 loop {
93 tokio::select! {
94 // Handle file system events
95 result = rx.recv() => {
96 let Some(result) = result else {
97 break; // Channel closed
98 };
99
100 match result {
101 Ok(events) => {
102 // TODO: Handle rescan events, I don't fully understand the implication of them yet
103 // some issues:
104 // - https://github.com/notify-rs/notify/issues/434
105 // - https://github.com/notify-rs/notify/issues/412
106
107 let should_rebuild = events.iter().any(should_rebuild_for_event);
108
109 // If new folder are created or removed, add/remove watches as needed
110 for event in &events {
111 if let EventKind::Create(CreateKind::Folder) = event.kind {
112 for path in &event.paths {
113 if should_watch_path(path) {
114 if let Err(e) = debouncer.watch(path, RecursiveMode::Recursive) {
115 error!(name: "watch", "Failed to add watch for new directory {:?}: {}", path, e);
116 } else {
117 info!(name: "watch", "Added watch for new directory {:?}", path);
118 }
119 }
120 }
121 }
122
123 // TODO: This doesn't seem to always work, sometimes removed folders are considered renames (maybe because of trash?), but it's fine I think
124 if let EventKind::Remove(RemoveKind::Folder) = event.kind {
125 for path in &event.paths {
126 if should_watch_path(path) {
127 if let Err(e) = debouncer.unwatch(path) {
128 error!(name: "watch", "Failed to remove watch for directory {:?}: {}", path, e);
129 } else {
130 info!(name: "watch", "Removed watch for directory {:?}", path);
131 }
132 }
133 }
134 }
135 }
136
137 if should_rebuild {
138 if !dev_server_started {
139 // Initial build failed, retry it
140 info!(name: "watch", "Files changed, retrying initial build...");
141 let start_time = Instant::now();
142 match build_manager_watcher.do_initial_build().await {
143 Ok(true) => {
144 info!(name: "build", "Initial build succeeded! Starting web server...");
145 dev_server_started = true;
146
147 dev_server_handle =
148 Some(tokio::spawn(server::start_dev_web_server(
149 start_time,
150 sender_websocket_watcher.clone(),
151 host,
152 port,
153 None,
154 build_manager_watcher.current_status(),
155 )));
156 }
157 Ok(false) => {
158 // Still failing, continue waiting
159 }
160 Err(e) => {
161 error!(name: "build", "Failed to retry initial build: {}", e);
162 }
163 }
164 } else {
165 // Normal rebuild - spawn in background so file watcher can continue
166 info!(name: "watch", "Files changed, rebuilding...");
167 let build_manager_clone = build_manager_watcher.clone();
168 tokio::spawn(async move {
169 match build_manager_clone.start_build().await {
170 Ok(_) => {
171 // Build completed (success or failure already logged)
172 }
173 Err(e) => {
174 error!(name: "build", "Failed to start build: {}", e);
175 }
176 }
177 });
178 }
179 }
180 }
181 Err(errors) => {
182 for error in errors {
183 error!(name: "watch", "Watch error: {}", error);
184 }
185 }
186 }
187 }
188 // Monitor dev server - if it ends, file watcher ends too
189 _ = async {
190 if let Some(handle) = &mut dev_server_handle {
191 handle.await
192 } else {
193 std::future::pending().await // Never resolves if no dev server
194 }
195 } => {
196 break;
197 }
198 }
199 }
200 });
201
202 // Wait for either the web server, file watcher, or shutdown signal
203 tokio::select! {
204 _ = shutdown_signal() => {
205 info!(name: "dev", "Shutting down dev environment...");
206 }
207 _ = async {
208 if let Some(web_server) = web_server_thread {
209 tokio::select! {
210 _ = web_server => {},
211 _ = file_watcher_task => {},
212 }
213 } else {
214 // No web server started yet, just wait for file watcher
215 // If it started the web server, it'll also close itself if the web server ends
216 file_watcher_task.await.unwrap();
217 }
218 } => {}
219 }
220 Ok(())
221}
222
223fn should_rebuild_for_event(event: &DebouncedEvent) -> bool {
224 event.paths.iter().any(|path| {
225 should_watch_path(path)
226 && match event.kind {
227 // Only rebuild on actual content modifications, not metadata changes
228 EventKind::Modify(ModifyKind::Data(_)) => true,
229 EventKind::Modify(ModifyKind::Name(_)) => true,
230 EventKind::Modify(ModifyKind::Any) => true,
231 EventKind::Modify(ModifyKind::Other) => true,
232 // Skip metadata-only changes (permissions, timestamps, etc.)
233 EventKind::Modify(ModifyKind::Metadata(_)) => false,
234 // Include file creation and removal
235 EventKind::Create(_) => true,
236 EventKind::Remove(_) => true,
237 // Skip other event types
238 _ => false,
239 }
240 })
241}
242
243fn should_watch_path(path: &Path) -> bool {
244 // Skip .DS_Store files
245 if let Some(file_name) = path.file_name()
246 && file_name == ".DS_Store"
247 {
248 return false;
249 }
250
251 // Skip dist and target directories, normally ignored by the watcher, but just in case
252 if path
253 .ancestors()
254 .any(|p| p.ends_with("dist") || p.ends_with("target") || p.ends_with(".git"))
255 {
256 return false;
257 }
258
259 true
260}
261
262async fn shutdown_signal() {
263 let ctrl_c = async {
264 signal::ctrl_c()
265 .await
266 .expect("failed to install Ctrl+C handler");
267 };
268
269 #[cfg(unix)]
270 let terminate = async {
271 signal::unix::signal(signal::unix::SignalKind::terminate())
272 .expect("failed to install signal handler")
273 .recv()
274 .await;
275 };
276
277 #[cfg(not(unix))]
278 let terminate = std::future::pending::<()>();
279
280 tokio::select! {
281 _ = ctrl_c => {},
282 _ = terminate => {},
283 }
284}