fob_cli/dev/
builder.rs

1//! Development mode builder that wraps the unified build function.
2//!
3//! Provides optimized rebuilds by:
4//! - Keeping bundles in memory for instant serving
5//! - Writing to disk asynchronously in the background
6//! - Reusing build configuration and utilities
7
8use 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
17/// Development builder wrapping production build function.
18///
19/// Handles both initial builds (with disk write) and incremental rebuilds
20/// (memory + async disk write).
21pub struct DevBuilder {
22    /// Base configuration for building
23    config: FobConfig,
24    /// Working directory
25    cwd: std::path::PathBuf,
26}
27
28impl DevBuilder {
29    /// Create a new development builder.
30    ///
31    /// # Arguments
32    ///
33    /// * `config` - Base bundler configuration
34    /// * `cwd` - Working directory for resolving paths
35    ///
36    /// # Returns
37    ///
38    /// Configured DevBuilder instance
39    pub fn new(config: FobConfig, cwd: std::path::PathBuf) -> Self {
40        Self { config, cwd }
41    }
42
43    /// Perform initial build with disk write.
44    ///
45    /// This is called once on server startup to ensure output files exist.
46    /// Delegates to the unified production build function.
47    ///
48    /// # Returns
49    ///
50    /// Tuple of (duration_ms, bundle_cache, asset_registry)
51    ///
52    /// # Errors
53    ///
54    /// Returns errors from the underlying build process
55    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        // Build cache by reading the output files, with URL rewriting if assets exist
68        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    /// Perform incremental rebuild.
78    ///
79    /// This is optimized for dev mode:
80    /// 1. Build in memory first (fast)
81    /// 2. Populate cache for instant serving
82    /// 3. Write to disk asynchronously
83    ///
84    /// # Returns
85    ///
86    /// Tuple of (duration_ms, bundle_cache, asset_registry)
87    ///
88    /// # Errors
89    ///
90    /// Returns errors from the build process
91    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        // For now, delegate to unified build since we're using the production
101        // build function which already writes to disk. In a full implementation,
102        // we would intercept the bundle output before disk write.
103        //
104        // TODO: Enhance fob-core to support in-memory builds without disk I/O
105        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        // Build cache by reading the output files, with URL rewriting if assets exist
110        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    /// Build cache from disk-written files.
120    ///
121    /// Reads the output directory and loads files into memory cache.
122    /// This allows serving without repeated disk I/O.
123    ///
124    /// # Security
125    ///
126    /// - Only reads files from the configured output directory
127    /// - Validates file paths to prevent directory traversal
128    /// - Limits file size to prevent memory exhaustion
129    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        // Read directory entries
140        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            // Skip directories and non-files
146            if !path.is_file() {
147                continue;
148            }
149
150            // Security: Validate path is within output directory
151            if !path.starts_with(&out_dir) {
152                continue;
153            }
154
155            // Get file name for URL path
156            let file_name = match path.file_name() {
157                Some(name) => name.to_string_lossy().to_string(),
158                None => continue,
159            };
160
161            // Determine content type
162            let content_type = Self::content_type_from_extension(&file_name);
163
164            // Security: Limit file size (10MB max)
165            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            // Read file content
177            let content = fs::read(&path).await?;
178
179            // Add to cache with URL path
180            let url_path = format!("/{}", file_name);
181            cache.insert(url_path, content, content_type);
182        }
183
184        Ok(cache)
185    }
186
187    /// Determine MIME type from file extension.
188    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    /// Rewrite asset URLs in JavaScript files from disk.
207    ///
208    /// Transforms `new URL(path, import.meta.url)` patterns to direct URLs
209    /// that point to the `/__fob_assets__/*` endpoint.
210    ///
211    /// This reads JavaScript files from the output directory, rewrites them,
212    /// and returns them in the cache.
213    ///
214    /// # Arguments
215    ///
216    /// * `registry` - Asset registry with URL mappings
217    ///
218    /// # Returns
219    ///
220    /// Result containing rewritten cache
221    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        // Build URL map: specifier → public URL
230        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        // Read directory entries
253        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            // Skip directories and non-files
259            if !path.is_file() {
260                continue;
261            }
262
263            // Security: Validate path is within output directory
264            if !path.starts_with(&out_dir) {
265                continue;
266            }
267
268            // Get file name for URL path
269            let file_name = match path.file_name() {
270                Some(name) => name.to_string_lossy().to_string(),
271                None => continue,
272            };
273
274            // Determine content type
275            let content_type = Self::content_type_from_extension(&file_name);
276
277            // Security: Limit file size (10MB max)
278            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            // Read file content
290            let content = fs::read(&path).await?;
291
292            // Rewrite JavaScript files if we have assets to rewrite
293            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            // Add to cache with URL path
312            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    // DevBuilder tests removed - mode detection no longer exists
325    // Content type helpers still tested below
326
327    #[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}