use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use tracing::debug;
#[derive(Debug, thiserror::Error)]
pub enum PreviewError {
#[error("Failed to bind to port {port}: {source}")]
BindFailed { port: u16, source: std::io::Error },
#[error("Site directory not found: {}", .0.display())]
SiteDirectoryNotFound(PathBuf),
#[error("Failed to read file {}: {source}", path.display())]
FileReadError {
path: PathBuf,
source: std::io::Error,
},
#[error("Failed to open browser: {0}")]
BrowserOpenFailed(String),
#[error("Server error: {0}")]
ServerError(String),
}
#[derive(Debug, Clone)]
pub struct PreviewConfig {
pub site_dir: PathBuf,
pub port: u16,
pub open_browser: bool,
}
impl Default for PreviewConfig {
fn default() -> Self {
Self {
site_dir: PathBuf::from("."),
port: 8080,
open_browser: true,
}
}
}
fn resolve_site_dir(path: &std::path::Path) -> Result<PathBuf, PreviewError> {
super::resolve_site_dir(path)
.map_err(|_| PreviewError::SiteDirectoryNotFound(path.to_path_buf()))
}
const MIME_APPLICATION_OCTET_STREAM: &str = "application/octet-stream";
const MIME_TEXT_PLAIN: &str = "text/plain";
fn guess_mime_type(path: &std::path::Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("html") | Some("htm") => "text/html; charset=utf-8",
Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("wasm") => "application/wasm",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
Some("ico") => "image/x-icon",
Some("txt") => "text/plain; charset=utf-8",
Some("xml") => "application/xml",
Some("pdf") => "application/pdf",
Some("bin") => MIME_APPLICATION_OCTET_STREAM,
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("ttf") => "font/ttf",
Some("otf") => "font/otf",
Some("eot") => "application/vnd.ms-fontobject",
Some("mp4") => "video/mp4",
Some("webm") => "video/webm",
Some("mp3") => "audio/mpeg",
Some("ogg") => "audio/ogg",
Some("wav") => "audio/wav",
Some("zip") => "application/zip",
Some("gz") => "application/gzip",
Some("tar") => "application/x-tar",
_ => MIME_APPLICATION_OCTET_STREAM,
}
}
fn build_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
build_response_with_content_length(status, content_type, body, None)
}
fn build_response_with_content_length(
status: u16,
content_type: &str,
body: Vec<u8>,
content_length_override: Option<usize>,
) -> Vec<u8> {
let status_text = match status {
200 => "OK",
304 => "Not Modified",
400 => "Bad Request",
405 => "Method Not Allowed",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Unknown",
};
let content_length = content_length_override.unwrap_or(body.len());
let headers = format!(
"HTTP/1.1 {} {}\r\n\
Content-Type: {}\r\n\
Content-Length: {}\r\n\
Cross-Origin-Opener-Policy: same-origin\r\n\
Cross-Origin-Embedder-Policy: require-corp\r\n\
Cross-Origin-Resource-Policy: same-origin\r\n\
Cache-Control: no-cache\r\n\
Connection: close\r\n\
\r\n",
status, status_text, content_type, content_length
);
let mut response = headers.into_bytes();
response.extend(body);
response
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HeadLengthSource {
Metadata,
FallbackRead,
}
fn head_content_length_with_metadata_hint(
file_path: &std::path::Path,
metadata_length: std::io::Result<u64>,
) -> std::io::Result<(usize, HeadLengthSource)> {
match metadata_length {
Ok(metadata_length) => match usize::try_from(metadata_length) {
Ok(length) => Ok((length, HeadLengthSource::Metadata)),
Err(_) => {
let bytes = std::fs::read(file_path)?;
Ok((bytes.len(), HeadLengthSource::FallbackRead))
}
},
Err(_) => {
let bytes = std::fs::read(file_path)?;
Ok((bytes.len(), HeadLengthSource::FallbackRead))
}
}
}
fn head_content_length(file_path: &std::path::Path) -> std::io::Result<(usize, HeadLengthSource)> {
let metadata_length = std::fs::metadata(file_path).map(|meta| meta.len());
head_content_length_with_metadata_hint(file_path, metadata_length)
}
fn head_content_length_from_hint_or_fs(
file_path: &std::path::Path,
metadata_length_hint: Option<u64>,
) -> std::io::Result<(usize, HeadLengthSource)> {
match metadata_length_hint {
Some(metadata_length) => {
head_content_length_with_metadata_hint(file_path, Ok(metadata_length))
}
None => head_content_length(file_path),
}
}
fn handle_request_with_site_root(site_root_canonical: &std::path::Path, request: &str) -> Vec<u8> {
let request_line = request.lines().next().unwrap_or("");
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
return build_response(400, MIME_TEXT_PLAIN, b"Bad Request".to_vec());
}
let method = parts[0];
let raw_path = parts[1];
if method != "GET" && method != "HEAD" {
return build_response(405, MIME_TEXT_PLAIN, b"Method Not Allowed".to_vec());
}
let path_only = raw_path
.split('?')
.next()
.unwrap_or(raw_path)
.split('#')
.next()
.unwrap_or(raw_path);
let decoded_path = urlencoding::decode(path_only).unwrap_or_else(|_| path_only.into());
let request_path = decoded_path.trim_start_matches('/');
if request_path.contains("..") {
return build_response(400, MIME_TEXT_PLAIN, b"Invalid Path".to_vec());
}
let file_path = if request_path.is_empty() || request_path == "/" {
site_root_canonical.join("index.html")
} else {
site_root_canonical.join(request_path)
};
let canonical = match file_path.canonicalize() {
Ok(p) => p,
Err(_) => {
let with_index = file_path.join("index.html");
match with_index.canonicalize() {
Ok(p) => p,
Err(_) => {
return build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec());
}
}
}
};
if !canonical.starts_with(site_root_canonical) {
return build_response(400, MIME_TEXT_PLAIN, b"Invalid Path".to_vec());
}
let mut file_to_read = canonical.clone();
let mut metadata_length_hint = None;
if let Ok(meta) = std::fs::metadata(&canonical) {
if meta.is_dir() {
let index_path = canonical.join("index.html");
let index_canonical = match index_path.canonicalize() {
Ok(path) => path,
Err(_) => {
return build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec());
}
};
if !index_canonical.starts_with(site_root_canonical) {
return build_response(400, MIME_TEXT_PLAIN, b"Invalid Path".to_vec());
}
match std::fs::metadata(&index_canonical) {
Ok(index_meta) if index_meta.is_file() => {
metadata_length_hint = Some(index_meta.len());
file_to_read = index_canonical;
}
_ => {
return build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec());
}
}
} else {
metadata_length_hint = Some(meta.len());
}
}
let request_started = Instant::now();
if method == "HEAD" {
match head_content_length_from_hint_or_fs(&file_to_read, metadata_length_hint) {
Ok((content_length, length_source)) => {
let mime = guess_mime_type(&file_to_read);
debug!(
method = method,
request_path = %request_path,
file_path = %file_to_read.display(),
status = 200,
size_source = ?length_source,
content_length = content_length,
elapsed_ms = request_started.elapsed().as_millis(),
"Preview served HEAD request"
);
build_response_with_content_length(200, mime, Vec::new(), Some(content_length))
}
Err(err) => {
debug!(
method = method,
request_path = %request_path,
file_path = %file_to_read.display(),
status = 404,
error = %err,
elapsed_ms = request_started.elapsed().as_millis(),
"Preview HEAD request failed"
);
build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec())
}
}
} else {
match std::fs::read(&file_to_read) {
Ok(contents) => {
let content_length = contents.len();
let mime = guess_mime_type(&file_to_read);
debug!(
method = method,
request_path = %request_path,
file_path = %file_to_read.display(),
status = 200,
size_source = "body_read",
content_length = content_length,
elapsed_ms = request_started.elapsed().as_millis(),
"Preview served GET request"
);
build_response(200, mime, contents)
}
Err(err) => {
debug!(
method = method,
request_path = %request_path,
file_path = %file_to_read.display(),
status = 404,
error = %err,
elapsed_ms = request_started.elapsed().as_millis(),
"Preview GET request failed"
);
build_response(404, MIME_TEXT_PLAIN, b"Not Found".to_vec())
}
}
}
}
#[cfg(test)]
fn handle_request(site_dir: &std::path::Path, request: &str) -> Vec<u8> {
let site_root_canonical = match site_dir.canonicalize() {
Ok(p) => p,
Err(_) => {
return build_response(500, MIME_TEXT_PLAIN, b"Internal Server Error".to_vec());
}
};
handle_request_with_site_root(&site_root_canonical, request)
}
fn handle_connection(mut stream: std::net::TcpStream, site_dir: &std::path::Path) {
use std::io::{Read, Write};
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
let mut buf = vec![0u8; 8192];
let n = match stream.read(&mut buf) {
Ok(n) if n > 0 => n,
_ => return,
};
let request = String::from_utf8_lossy(&buf[..n]);
let response = handle_request_with_site_root(site_dir, &request);
let _ = stream.write_all(&response);
let _ = stream.flush();
let _ = stream.shutdown(std::net::Shutdown::Both);
}
pub async fn start_preview_server(config: PreviewConfig) -> Result<(), PreviewError> {
let resolved_site_dir = resolve_site_dir(&config.site_dir)?;
let site_dir = Arc::new(
resolved_site_dir
.canonicalize()
.map_err(|_| PreviewError::SiteDirectoryNotFound(config.site_dir.clone()))?,
);
let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
let listener = std::net::TcpListener::bind(addr).map_err(|e| PreviewError::BindFailed {
port: config.port,
source: e,
})?;
eprintln!();
eprintln!(
"\x1b[1;32m\u{1F310}\x1b[0m Preview server running at \x1b[1;36mhttp://localhost:{}\x1b[0m",
config.port
);
eprintln!(" Serving: \x1b[33m{}\x1b[0m", site_dir.display());
eprintln!(" Press \x1b[1mCtrl+C\x1b[0m to stop");
eprintln!();
if config.open_browser {
let url = format!("http://localhost:{}", config.port);
if let Err(e) = open_browser(&url) {
eprintln!("\x1b[33mWarning:\x1b[0m Could not open browser: {}", e);
eprintln!(" Please open \x1b[1;36m{}\x1b[0m manually", url);
}
}
asupersync::runtime::spawn_blocking(move || {
for stream_result in listener.incoming() {
match stream_result {
Ok(stream) => {
let site_dir = Arc::clone(&site_dir);
std::thread::spawn(move || {
handle_connection(stream, &site_dir);
});
}
Err(e) => {
eprintln!("Accept error: {}", e);
}
}
}
})
.await;
eprintln!("\x1b[32mPreview server stopped.\x1b[0m");
Ok(())
}
fn open_browser(url: &str) -> Result<(), PreviewError> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(url)
.spawn()
.map_err(|e| PreviewError::BrowserOpenFailed(e.to_string()))?;
}
#[cfg(target_os = "linux")]
{
let browsers = [
"xdg-open",
"firefox",
"chromium",
"google-chrome",
"x-www-browser",
];
let mut opened = false;
for browser in browsers {
if std::process::Command::new(browser).arg(url).spawn().is_ok() {
opened = true;
break;
}
}
if !opened {
return Err(PreviewError::BrowserOpenFailed(
"No browser found. Install xdg-open or a web browser.".to_string(),
));
}
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn()
.map_err(|e| PreviewError::BrowserOpenFailed(e.to_string()))?;
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
return Err(PreviewError::BrowserOpenFailed(
"Unsupported platform for auto-open".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn content_length(resp: &str) -> Option<usize> {
resp.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("Content-Length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})
}
fn temp_site_with_index(contents: impl AsRef<[u8]>) -> TempDir {
let temp_dir = TempDir::new().expect("temp dir");
std::fs::write(temp_dir.path().join("index.html"), contents).expect("write index");
temp_dir
}
#[test]
fn test_guess_mime_type() {
assert_eq!(
guess_mime_type(std::path::Path::new("index.html")),
"text/html; charset=utf-8"
);
assert_eq!(
guess_mime_type(std::path::Path::new("app.js")),
"application/javascript; charset=utf-8"
);
assert_eq!(
guess_mime_type(std::path::Path::new("styles.css")),
"text/css; charset=utf-8"
);
assert_eq!(
guess_mime_type(std::path::Path::new("data.json")),
"application/json; charset=utf-8"
);
assert_eq!(
guess_mime_type(std::path::Path::new("module.wasm")),
"application/wasm"
);
assert_eq!(
guess_mime_type(std::path::Path::new("image.png")),
"image/png"
);
assert_eq!(
guess_mime_type(std::path::Path::new("unknown")),
"application/octet-stream"
);
}
#[test]
fn test_preview_config_default() {
let config = PreviewConfig::default();
assert_eq!(config.port, 8080);
assert!(config.open_browser);
}
#[test]
fn test_preview_error_display_and_source_are_preserved() {
let bind = PreviewError::BindFailed {
port: 8081,
source: std::io::Error::new(std::io::ErrorKind::AddrInUse, "busy"),
};
assert_eq!(bind.to_string(), "Failed to bind to port 8081: busy");
assert_eq!(
std::error::Error::source(&bind)
.expect("bind source")
.to_string(),
"busy"
);
let missing = PreviewError::SiteDirectoryNotFound(PathBuf::from("/tmp/missing-site"));
assert_eq!(
missing.to_string(),
"Site directory not found: /tmp/missing-site"
);
assert!(std::error::Error::source(&missing).is_none());
let read = PreviewError::FileReadError {
path: PathBuf::from("/tmp/site/app.js"),
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
};
assert_eq!(
read.to_string(),
"Failed to read file /tmp/site/app.js: denied"
);
assert_eq!(
std::error::Error::source(&read)
.expect("file read source")
.to_string(),
"denied"
);
let browser = PreviewError::BrowserOpenFailed("missing opener".to_string());
assert_eq!(
browser.to_string(),
"Failed to open browser: missing opener"
);
assert!(std::error::Error::source(&browser).is_none());
let server = PreviewError::ServerError("worker stopped".to_string());
assert_eq!(server.to_string(), "Server error: worker stopped");
assert!(std::error::Error::source(&server).is_none());
}
#[test]
fn test_resolve_site_dir_accepts_bundle_root() {
let temp_dir = TempDir::new().expect("temp dir");
let bundle_root = temp_dir.path();
std::fs::create_dir(bundle_root.join("site")).expect("create site dir");
let resolved = resolve_site_dir(bundle_root).expect("resolve bundle root");
assert_eq!(resolved, bundle_root.join("site"));
}
#[test]
fn test_build_response_headers() {
let response = build_response(200, "text/html", b"<html></html>".to_vec());
let response_str = String::from_utf8_lossy(&response);
assert!(response_str.contains("HTTP/1.1 200 OK"));
assert!(response_str.contains("Content-Type: text/html"));
assert!(response_str.contains("Cross-Origin-Opener-Policy: same-origin"));
assert!(response_str.contains("Cross-Origin-Embedder-Policy: require-corp"));
assert!(response_str.contains("Cross-Origin-Resource-Policy: same-origin"));
}
#[test]
fn test_handle_request_bad_method() {
let site_dir = std::path::Path::new("/tmp");
let response = handle_request(site_dir, "POST / HTTP/1.1\r\n");
let response_str = String::from_utf8_lossy(&response);
assert!(response_str.contains("HTTP/1.1 405 Method Not Allowed"));
assert!(response_str.contains("Method Not Allowed"));
}
#[test]
fn test_handle_request_bad_path() {
let site_dir = std::path::Path::new("/tmp");
let response = handle_request(site_dir, "GET /../etc/passwd HTTP/1.1\r\n");
let response_str = String::from_utf8_lossy(&response);
assert!(response_str.contains("400") || response_str.contains("Invalid"));
}
#[test]
#[cfg(unix)]
fn test_handle_request_rejects_directory_index_symlink_escape() {
use std::os::unix::fs::symlink;
let temp_dir = temp_site_with_index("<html>root</html>");
let outside = TempDir::new().expect("outside dir");
let outside_file = outside.path().join("secret.html");
std::fs::write(&outside_file, "<html>outside secret</html>").expect("write outside file");
let nested = temp_dir.path().join("nested");
std::fs::create_dir(&nested).expect("create nested dir");
symlink(&outside_file, nested.join("index.html")).expect("symlink nested index");
let get_response = handle_request(temp_dir.path(), "GET /nested/ HTTP/1.1\r\n");
let get_str = String::from_utf8_lossy(&get_response);
assert!(get_str.contains("HTTP/1.1 400"));
assert!(!get_str.contains("outside secret"));
let head_response = handle_request(temp_dir.path(), "HEAD /nested/ HTTP/1.1\r\n");
let head_str = String::from_utf8_lossy(&head_response);
assert!(head_str.contains("HTTP/1.1 400"));
}
#[test]
fn test_handle_request_serves_index_with_coi_headers() {
let temp_dir = temp_site_with_index("<!doctype html><html>ok</html>");
let site_dir = temp_dir.path();
std::fs::write(
site_dir.join("sw.js"),
"self.addEventListener('install', () => {});",
)
.expect("write sw.js");
let index_response = handle_request(site_dir, "GET / HTTP/1.1\r\n");
let index_str = String::from_utf8_lossy(&index_response);
assert!(index_str.contains("HTTP/1.1 200 OK"));
assert!(index_str.contains("Content-Type: text/html; charset=utf-8"));
assert!(index_str.contains("Cross-Origin-Opener-Policy: same-origin"));
assert!(index_str.contains("Cross-Origin-Embedder-Policy: require-corp"));
assert!(index_str.contains("Cross-Origin-Resource-Policy: same-origin"));
let sw_response = handle_request(site_dir, "GET /sw.js HTTP/1.1\r\n");
let sw_str = String::from_utf8_lossy(&sw_response);
assert!(sw_str.contains("HTTP/1.1 200 OK"));
assert!(sw_str.contains("Content-Type: application/javascript; charset=utf-8"));
}
#[test]
fn test_handle_request_head_preserves_content_length() {
let body = "<!doctype html><html>head-check</html>";
let temp_dir = temp_site_with_index(body);
let site_dir = temp_dir.path();
let get_response = handle_request(site_dir, "GET / HTTP/1.1\r\n");
let head_response = handle_request(site_dir, "HEAD / HTTP/1.1\r\n");
let get_str = String::from_utf8_lossy(&get_response);
let head_str = String::from_utf8_lossy(&head_response);
let get_len = content_length(&get_str).expect("GET content-length");
let head_len = content_length(&head_str).expect("HEAD content-length");
assert_eq!(head_len, get_len);
assert!(head_str.ends_with("\r\n\r\n"));
assert!(!head_str.contains("head-check"));
}
#[test]
fn test_head_content_length_prefers_metadata() {
let temp_dir = TempDir::new().expect("temp dir");
let file_path = temp_dir.path().join("asset.bin");
let body = vec![b'x'; 4096];
std::fs::write(&file_path, &body).expect("write asset");
let (length, source) =
head_content_length_with_metadata_hint(&file_path, Ok(body.len() as u64))
.expect("metadata length");
assert_eq!(length, body.len());
assert_eq!(source, HeadLengthSource::Metadata);
}
#[test]
fn test_head_content_length_falls_back_when_metadata_missing() {
let temp_dir = TempDir::new().expect("temp dir");
let file_path = temp_dir.path().join("asset.bin");
let body = vec![b'y'; 8192];
std::fs::write(&file_path, &body).expect("write asset");
let (length, source) = head_content_length_with_metadata_hint(
&file_path,
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"metadata unavailable",
)),
)
.expect("fallback length");
assert_eq!(length, body.len());
assert_eq!(source, HeadLengthSource::FallbackRead);
}
#[test]
fn test_handle_request_head_large_file_content_length() {
let body = vec![b'z'; 512 * 1024];
let temp_dir = temp_site_with_index(&body);
let site_dir = temp_dir.path();
let head_response = handle_request(site_dir, "HEAD / HTTP/1.1\r\n");
let head_str = String::from_utf8_lossy(&head_response);
assert_eq!(
content_length(&head_str).expect("HEAD content-length"),
body.len()
);
assert!(head_str.ends_with("\r\n\r\n"));
}
#[test]
fn test_head_content_length_from_hint_or_fs_with_hint_skips_fs_lookup() {
let missing_path = std::path::Path::new("/tmp/cass-preview-nonexistent-file-for-hint-test");
let (length, source) = head_content_length_from_hint_or_fs(missing_path, Some(777))
.expect("metadata hint should succeed without filesystem access");
assert_eq!(length, 777);
assert_eq!(source, HeadLengthSource::Metadata);
}
#[test]
fn test_handle_request_with_site_root_precanonicalized() {
let temp_dir = temp_site_with_index("<html>canonical</html>");
let site_dir = temp_dir.path();
let canonical_root = site_dir.canonicalize().expect("canonicalize root");
let response = handle_request_with_site_root(&canonical_root, "GET / HTTP/1.1\r\n");
let response_str = String::from_utf8_lossy(&response);
assert!(response_str.contains("HTTP/1.1 200 OK"));
assert!(response_str.contains("canonical"));
}
#[test]
fn test_handle_request_wrapper_accepts_uncanonicalized_site_dir() {
let temp_dir = temp_site_with_index("<html>wrapper</html>");
let site_dir = temp_dir.path();
let dotted = site_dir.join(".");
let response = handle_request(&dotted, "GET / HTTP/1.1\r\n");
let response_str = String::from_utf8_lossy(&response);
assert!(response_str.contains("HTTP/1.1 200 OK"));
assert!(response_str.contains("wrapper"));
}
}