1use crate::config::FobConfig;
9use crate::dev::BundleCache;
10use crate::error::Result;
11use fob_bundler::builders::asset_processor;
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::time::Instant;
15use tracing;
16
17pub struct DevBuilder {
22 config: FobConfig,
24 cwd: std::path::PathBuf,
26}
27
28impl DevBuilder {
29 pub fn new(config: FobConfig, cwd: std::path::PathBuf) -> Self {
40 Self { config, cwd }
41 }
42
43 pub async fn initial_build(
56 &self,
57 ) -> Result<(
58 u64,
59 BundleCache,
60 Option<std::sync::Arc<fob_bundler::builders::asset_registry::AssetRegistry>>,
61 )> {
62 let start = Instant::now();
63
64 let result = crate::commands::build::build_with_result(&self.config, &self.cwd).await?;
65 let duration_ms = start.elapsed().as_millis() as u64;
66
67 let cache = if let Some(ref registry) = result.asset_registry {
69 self.build_cache_with_rewriting(registry).await?
70 } else {
71 self.build_cache_from_disk().await?
72 };
73
74 Ok((duration_ms, cache, result.asset_registry))
75 }
76
77 pub async fn rebuild(
92 &self,
93 ) -> Result<(
94 u64,
95 BundleCache,
96 Option<std::sync::Arc<fob_bundler::builders::asset_registry::AssetRegistry>>,
97 )> {
98 let start = Instant::now();
99
100 let result = crate::commands::build::build_with_result(&self.config, &self.cwd).await?;
106
107 let duration_ms = start.elapsed().as_millis() as u64;
108
109 let cache = if let Some(ref registry) = result.asset_registry {
111 self.build_cache_with_rewriting(registry).await?
112 } else {
113 self.build_cache_from_disk().await?
114 };
115
116 Ok((duration_ms, cache, result.asset_registry))
117 }
118
119 pub async fn build_cache_from_disk(&self) -> Result<BundleCache> {
130 use tokio::fs;
131
132 let mut cache = BundleCache::new();
133 let out_dir = if self.config.out_dir.is_absolute() {
134 self.config.out_dir.clone()
135 } else {
136 self.cwd.join(&self.config.out_dir)
137 };
138
139 let mut entries = fs::read_dir(&out_dir).await?;
141
142 while let Some(entry) = entries.next_entry().await? {
143 let path = entry.path();
144
145 if !path.is_file() {
147 continue;
148 }
149
150 if !path.starts_with(&out_dir) {
152 continue;
153 }
154
155 let file_name = match path.file_name() {
157 Some(name) => name.to_string_lossy().to_string(),
158 None => continue,
159 };
160
161 let content_type = Self::content_type_from_extension(&file_name);
163
164 const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
166 let metadata = entry.metadata().await?;
167 if metadata.len() > MAX_FILE_SIZE {
168 crate::ui::warning(&format!(
169 "Skipping large file {}: {} bytes",
170 file_name,
171 metadata.len()
172 ));
173 continue;
174 }
175
176 let content = fs::read(&path).await?;
178
179 let url_path = format!("/{}", file_name);
181 cache.insert(url_path, content, content_type);
182 }
183
184 Ok(cache)
185 }
186
187 fn content_type_from_extension(filename: &str) -> String {
189 if filename.ends_with(".js") || filename.ends_with(".mjs") {
190 "application/javascript".to_string()
191 } else if filename.ends_with(".map") {
192 "application/json".to_string()
193 } else if filename.ends_with(".d.ts") {
194 "text/plain".to_string()
195 } else if filename.ends_with(".css") {
196 "text/css".to_string()
197 } else if filename.ends_with(".html") {
198 "text/html".to_string()
199 } else if filename.ends_with(".wasm") {
200 "application/wasm".to_string()
201 } else {
202 "application/octet-stream".to_string()
203 }
204 }
205
206 async fn build_cache_with_rewriting(
222 &self,
223 registry: &Arc<fob_bundler::builders::asset_registry::AssetRegistry>,
224 ) -> Result<BundleCache> {
225 use tokio::fs;
226
227 tracing::debug!("[URL_REWRITE] Building cache with URL rewriting");
228
229 let mut url_map: HashMap<String, String> = HashMap::new();
231
232 for asset in registry.all_assets() {
233 if let Some(url_path) = &asset.url_path {
234 tracing::debug!(
235 "[URL_REWRITE] Mapping: '{}' -> '{}'",
236 asset.specifier,
237 url_path
238 );
239 url_map.insert(asset.specifier.clone(), url_path.clone());
240 }
241 }
242
243 tracing::debug!("[URL_REWRITE] URL map has {} entries", url_map.len());
244
245 let mut cache = BundleCache::new();
246 let out_dir = if self.config.out_dir.is_absolute() {
247 self.config.out_dir.clone()
248 } else {
249 self.cwd.join(&self.config.out_dir)
250 };
251
252 let mut entries = fs::read_dir(&out_dir).await?;
254
255 while let Some(entry) = entries.next_entry().await? {
256 let path = entry.path();
257
258 if !path.is_file() {
260 continue;
261 }
262
263 if !path.starts_with(&out_dir) {
265 continue;
266 }
267
268 let file_name = match path.file_name() {
270 Some(name) => name.to_string_lossy().to_string(),
271 None => continue,
272 };
273
274 let content_type = Self::content_type_from_extension(&file_name);
276
277 const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
279 let metadata = entry.metadata().await?;
280 if metadata.len() > MAX_FILE_SIZE {
281 crate::ui::warning(&format!(
282 "Skipping large file {}: {} bytes",
283 file_name,
284 metadata.len()
285 ));
286 continue;
287 }
288
289 let content = fs::read(&path).await?;
291
292 let final_content = if content_type == "application/javascript" && !url_map.is_empty() {
294 tracing::debug!("[URL_REWRITE] Processing JS file: {}", file_name);
295 if let Ok(code) = String::from_utf8(content.clone()) {
296 let rewritten = asset_processor::rewrite_urls(&code, &url_map);
297 if rewritten != code {
298 tracing::debug!("[URL_REWRITE] URLs were rewritten in {}", file_name);
299 } else {
300 tracing::debug!("[URL_REWRITE] No changes needed for {}", file_name);
301 }
302 rewritten.into_bytes()
303 } else {
304 tracing::warn!("[URL_REWRITE] Failed to parse {} as UTF-8", file_name);
305 content
306 }
307 } else {
308 content
309 };
310
311 let url_path = format!("/{}", file_name);
313 cache.insert(url_path, final_content, content_type);
314 }
315
316 Ok(cache)
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
328 fn test_content_type_js() {
329 assert_eq!(
330 DevBuilder::content_type_from_extension("bundle.js"),
331 "application/javascript"
332 );
333 assert_eq!(
334 DevBuilder::content_type_from_extension("module.mjs"),
335 "application/javascript"
336 );
337 }
338
339 #[test]
340 fn test_content_type_map() {
341 assert_eq!(
342 DevBuilder::content_type_from_extension("bundle.js.map"),
343 "application/json"
344 );
345 }
346
347 #[test]
348 fn test_content_type_dts() {
349 assert_eq!(
350 DevBuilder::content_type_from_extension("types.d.ts"),
351 "text/plain"
352 );
353 }
354
355 #[test]
356 fn test_content_type_unknown() {
357 assert_eq!(
358 DevBuilder::content_type_from_extension("file.xyz"),
359 "application/octet-stream"
360 );
361 }
362}