dumpling 0.1.0

A fast JavaScript runtime and bundler in Rust
Documentation
use std::path::PathBuf;
use std::env;

use boa_engine::{Context, Source};
use boa_engine::property::Attribute;
use boa_engine::object::ObjectInitializer;
use boa_runtime::Console;

use crate::bundler::Bundler;
use crate::error::Result;
use crate::es202x::ES202XFeatures;

pub async fn run_file(file: PathBuf, _args: Vec<String>) -> Result<()> {
    let current_dir = env::current_dir()?;
    let mut bundler = Bundler::new(current_dir);

    // Bundle the entry point with all dependencies (CJS format for require/module.exports)
    let bundle: String = bundler.bundle_to_string(&file, "cjs", false).await?;

    // Create Boa context
    let mut context = Context::default();

    // Register console for console.log, etc.
    let console = Console::init(&mut context);
    context
        .register_global_property(Console::NAME, console, Attribute::all())
        .map_err(|e| crate::error::DumplingError::Build(format!("Failed to register console: {}", e)))?;

    // Register process.env for Node.js compatibility
    let process_env = create_process_env(&mut context)?;
    context
        .register_global_property("process", process_env, Attribute::all())
        .map_err(|e| crate::error::DumplingError::Build(format!("Failed to register process: {}", e)))?;

    // Polyfill Object.hasOwn (ES2022) if not present
    let has_own_polyfill = b"if(typeof Object.hasOwn!=='function'){Object.hasOwn=function(o,p){return Object.prototype.hasOwnProperty.call(o,p);};}";
    context
        .eval(Source::from_bytes(has_own_polyfill))
        .map_err(|e| crate::error::DumplingError::Build(format!("Polyfill error: {}", e)))?;

    // Initialize ES202X features
    ES202XFeatures::init(&mut context)
        .map_err(|e| crate::error::DumplingError::Build(format!("Failed to initialize ES202X features: {}", e)))?;

    // Execute the bundled JavaScript with enhanced error handling
    match context.eval(Source::from_bytes(bundle.as_bytes())) {
        Ok(_) => Ok(()),
        Err(e) => {
            // Create detailed error with source map information if available
            let mut error_context = crate::error::ErrorContext::new()
                .with_file(file.clone());
            
            // Try to extract line and column information from the error
            let error_string = format!("{}", e);
            if let Some(line_col) = extract_line_column_from_error(&error_string) {
                error_context = error_context
                    .with_line(line_col.0)
                    .with_column(line_col.1);
                
                // Extract relevant source code line
                if let Ok(source_content) = std::fs::read_to_string(&file) {
                    let lines: Vec<&str> = source_content.lines().collect();
                    if let Some(source_line) = lines.get((line_col.0 as usize).saturating_sub(1)) {
                        error_context = error_context.with_source_code(format!("{}\n{}",
                            source_line,
                            " ".repeat(line_col.1 as usize) + "^"
                        ));
                    }
                }
            }
            
            Err(crate::error::DumplingError::Runtime(e).with_context(error_context).into())
        }
    }
}

fn create_process_env(context: &mut Context) -> std::result::Result<boa_engine::JsValue, crate::error::DumplingError> {
    // Build env object with real environment variables
    let mut env_init = ObjectInitializer::new(context);
    for (key, value) in env::vars() {
        env_init.property(key, value, Attribute::all());
    }
    let env_js = env_init.build();

    let cwd = env::current_dir()
        .unwrap_or_else(|_| PathBuf::from("."))
        .display()
        .to_string();

    let platform = std::env::consts::OS;
    let arch = std::env::consts::ARCH;

    let process = ObjectInitializer::new(context)
        .property("env", env_js, Attribute::all())
        .property("cwd", cwd, Attribute::all())
        .property("platform", platform, Attribute::all())
        .property("arch", arch, Attribute::all())
        .build();

    Ok(process.into())
}

/// Extract line and column information from error messages
fn extract_line_column_from_error(error_str: &str) -> Option<(u32, u32)> {
    // Try to match patterns like "at line 123:45" or "at line 123, column 45"
    let line_col_regex = regex::Regex::new(r"at line (\d+)(?::(\d+))|at line (\d+), column (\d+)").ok()?;
    
    if let Some(caps) = line_col_regex.captures(error_str) {
        let line = caps.get(1).or(caps.get(3))?.as_str().parse().ok()?;
        let column = caps.get(2).or(caps.get(4))
            .map(|m| m.as_str().parse().unwrap_or(0))
            .unwrap_or(0);
        return Some((line, column));
    }
    
    None
}

#[derive(Clone)]
enum HmrUpdate {
    Reload,
    Css { path: String, content: String },
}

pub async fn start_dev_server(host: String, port: u16) -> Result<()> {
    use tokio::net::TcpListener;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::sync::broadcast;
    use std::sync::mpsc;
    use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};

    let addr = format!("{}:{}", host, port);
    let listener = TcpListener::bind(&addr).await
        .map_err(|e| crate::error::DumplingError::Build(format!("Failed to bind to {}: {}", addr, e)))?;

    let (reload_tx, _) = broadcast::channel::<HmrUpdate>(16);
    let root = env::current_dir()?;

    // File watcher - bridge sync notify to async broadcast, send changed paths
    let (fs_tx, fs_rx) = mpsc::channel::<Vec<std::path::PathBuf>>();
    let watcher_root = root.clone();
    if let Ok(mut watcher) = RecommendedWatcher::new(
        move |res: std::result::Result<notify::Event, notify::Error>| {
            if let Ok(ev) = res {
                if !ev.paths.is_empty() {
                    let _ = fs_tx.send(ev.paths);
                }
            }
        },
        Config::default(),
    ) {
        if watcher.watch(&watcher_root, RecursiveMode::Recursive).is_ok() {
            std::mem::forget(watcher);
            let watcher_tx = reload_tx.clone();
            std::thread::spawn(move || {
                while let Ok(paths) = fs_rx.recv() {
                    let has_non_css = paths.iter().any(|p| {
                        p.extension().map(|e| e.to_string_lossy()) != Some(std::borrow::Cow::Borrowed("css"))
                    });
                    if has_non_css {
                        let _ = watcher_tx.send(HmrUpdate::Reload);
                    } else {
                        for path in paths {
                            if path.extension().map(|e| e.to_string_lossy()) == Some(std::borrow::Cow::Borrowed("css"))
                                && path.is_file()
                            {
                                if let Ok(content) = std::fs::read_to_string(&path) {
                                    let hmr_path = path
                                        .file_name()
                                        .and_then(|n| n.to_str())
                                        .unwrap_or("style.css")
                                        .to_string();
                                    let _ = watcher_tx.send(HmrUpdate::Css {
                                        path: hmr_path,
                                        content,
                                    });
                                }
                            }
                        }
                    }
                }
            });
        }
    }

    println!("Starting dev server on http://{}", addr);
    println!("HMR enabled - CSS hot-reload, JS triggers full reload");
    println!("Press Ctrl+C to stop");

    loop {
        let (mut socket, _) = listener.accept().await
            .map_err(|e| crate::error::DumplingError::Build(format!("Accept error: {}", e)))?;

        let root = root.clone();
        let request = {
            let mut buf = [0u8; 8192];
            if let Ok(n) = socket.read(&mut buf).await {
                String::from_utf8_lossy(&buf[..n]).to_string()
            } else {
                continue;
            }
        };

        let path = parse_request_path(&request);
        let is_hmr = path == "/__hmr";

        if is_hmr {
            // HMR polling - returns JSON: {"type":"reload"} or {"type":"css","path":"...","content":"..."}
            let mut sub = reload_tx.subscribe();
            let update = sub.recv().await;
            let body = match update {
                Ok(HmrUpdate::Reload) => serde_json::json!({"type": "reload"}).to_string(),
                Ok(HmrUpdate::Css { path: p, content }) => {
                    serde_json::json!({"type": "css", "path": p, "content": content}).to_string()
                }
                Err(_) => serde_json::json!({"type": "reload"}).to_string(),
            };
            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                body.len(),
                body
            );
            let _ = socket.write_all(response.as_bytes()).await;
        } else {
            // Regular HTTP
            let file_path = if path == "/" || path.is_empty() {
                root.join("index.html")
            } else {
                root.join(path.trim_start_matches('/'))
            };

            let (status, body, content_type) = if file_path.exists() && file_path.is_file() {
                match tokio::fs::read_to_string(&file_path).await {
                    Ok(content) => {
                        let ct = content_type_for_path(&file_path);
                        let body = if ct == "text/html" {
                            inject_hmr_script(&content, &addr)
                        } else {
                            content
                        };
                        ("200 OK", body, ct)
                    }
                    Err(_) => ("500 Internal Server Error", "Internal Server Error".to_string(), "text/plain"),
                }
            } else {
                ("404 Not Found", "Not Found".to_string(), "text/plain")
            };

            let response = format!(
                "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                status,
                content_type,
                body.len(),
                body
            );
            let _ = socket.write_all(response.as_bytes()).await;
        }
    }
}

fn inject_hmr_script(html: &str, addr: &str) -> String {
    let error_overlay = r#"<div id="dumpling-error-overlay" style="display:none;position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.85);color:#ff6b6b;font-family:monospace;font-size:14px;padding:20px;overflow:auto;box-sizing:border-box"><div style="max-width:800px;margin:0 auto"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><strong style="font-size:18px">Runtime Error</strong><button id="dumpling-error-dismiss" style="background:#333;color:#fff;border:none;padding:8px 16px;cursor:pointer;border-radius:4px">Dismiss</button></div><pre id="dumpling-error-content" style="white-space:pre-wrap;word-break:break-all;margin:0;color:#eee"></pre></div></div>"#;
    let error_script = r#"
(function(){
  function showError(msg,stack){
    var o=document.getElementById('dumpling-error-overlay');
    var c=document.getElementById('dumpling-error-content');
    if(o&&c){c.textContent=msg+(stack?'\n\n'+stack:'');o.style.display='block';}
  }
  window.onerror=function(m,u,l,c,e){showError(m+(e&&e.stack?'\n'+e.stack:''),'');return false;};
  window.onunhandledrejection=function(e){showError('Unhandled rejection: '+(e.reason&&(e.reason.message||String(e.reason))),(e.reason&&e.reason.stack)||'');};
  var b=document.getElementById('dumpling-error-dismiss');
  if(b)b.onclick=function(){var o=document.getElementById('dumpling-error-overlay');if(o)o.style.display='none';};
})();
"#;
    let hmr_script = format!(
        r#"{}<script>{}</script><script>(function(){{function poll(){{fetch('http://{}/__hmr').then(function(r){{if(r.ok)return r.json()}}).then(function(d){{if(!d)return;if(d.type==='reload')location.reload();if(d.type==='css'){{var s=document.querySelector('[data-dumpling-hmr="'+d.path+'"]');if(s)s.textContent=d.content;else{{s=document.createElement('style');s.setAttribute('data-dumpling-hmr',d.path);s.textContent=d.content;(document.head||document.documentElement).appendChild(s)}}}}}}).catch(function(){{}});setTimeout(poll,1000)}}poll()}}</script></body>"#,
        error_overlay,
        error_script,
        addr
    );
    if html.contains("</body>") {
        html.replace("</body>", &hmr_script)
    } else {
        format!("{}{}", html, hmr_script)
    }
}

fn parse_request_path(request: &str) -> &str {
    request
        .lines()
        .next()
        .and_then(|line| line.split_whitespace().nth(1))
        .unwrap_or("/")
}

fn content_type_for_path(path: &PathBuf) -> &'static str {
    path.extension()
        .and_then(|e| e.to_str())
        .map(|ext| match ext {
            "html" => "text/html",
            "css" => "text/css",
            "js" => "application/javascript",
            "json" => "application/json",
            "png" => "image/png",
            "jpg" | "jpeg" => "image/jpeg",
            "gif" => "image/gif",
            "svg" => "image/svg+xml",
            "ico" => "image/x-icon",
            "woff" => "font/woff",
            "woff2" => "font/woff2",
            _ => "application/octet-stream",
        })
        .unwrap_or("application/octet-stream")
}