diff --git a/Cargo.lock b/Cargo.lock index 2b9020578..0d9d80b57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "autocfg" version = "1.4.0" @@ -282,6 +288,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "ciborium" version = "0.2.2" @@ -909,6 +921,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hypher" version = "0.1.5" @@ -1296,13 +1314,12 @@ dependencies = [ [[package]] name = "libfuzzer-sys" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96cfd5557eb82f2b83fed4955246c988d331975a002961b07c81584d107e7f7" +checksum = "9b9569d2f74e257076d8c6bfa73fb505b46b851e51ddaecc825944aa3bed17fa" dependencies = [ "arbitrary", "cc", - "once_cell", ] [[package]] @@ -2581,6 +2598,18 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -2718,6 +2747,7 @@ dependencies = [ "sigpipe", "tar", "tempfile", + "tiny_http", "toml", "typst", "typst-eval", diff --git a/Cargo.toml b/Cargo.toml index 639136ce5..b20d54e87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,6 +117,7 @@ tar = "0.4" tempfile = "3.7.0" thin-vec = "0.2.13" time = { version = "0.3.20", features = ["formatting", "macros", "parsing"] } +tiny_http = "0.12" tiny-skia = "0.11" toml = { version = "0.8", default-features = false, features = ["parse", "display"] } ttf-parser = "0.24.1" diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index 12cf94a7d..c859f043c 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -50,6 +50,7 @@ shell-escape = { workspace = true } sigpipe = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } +tiny_http = { workspace = true } toml = { workspace = true } ureq = { workspace = true } xz2 = { workspace = true, optional = true } diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 33fcb9fd8..ead932362 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -97,6 +97,21 @@ pub struct WatchCommand { /// Arguments for compilation. #[clap(flatten)] pub args: CompileArgs, + + /// Disables the built-in HTTP server for HTML export. + #[clap(long)] + pub no_serve: bool, + + /// Disables the injected live reload script for HTML export. The HTML that + /// is written to disk isn't affected either way. + #[clap(long)] + pub no_reload: bool, + + /// The port where HTML is served. + /// + /// Defaults to the first free port in the range 3000-3005. + #[clap(long)] + pub port: Option, } /// Initializes a new project from a template. diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 7d650fc80..01a6de1bc 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::fs::{self, File}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -20,8 +21,9 @@ use typst_pdf::{PdfOptions, PdfStandards}; use crate::args::{ CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat, - PdfStandard, + PdfStandard, WatchCommand, }; +use crate::server::HtmlServer; use crate::timings::Timer; use crate::watch::Status; @@ -33,15 +35,17 @@ type CodespanError = codespan_reporting::files::Error; /// Execute a compilation command. pub fn compile(timer: &mut Timer, command: &CompileCommand) -> StrResult<()> { - let mut config = CompileConfig::new(&command.args)?; + let mut config = CompileConfig::new(command)?; let mut world = SystemWorld::new(&command.args.input, &command.args.world, &command.args.process) .map_err(|err| eco_format!("{err}"))?; - timer.record(&mut world, |world| compile_once(world, &mut config, false))? + timer.record(&mut world, |world| compile_once(world, &mut config))? } /// A preprocessed `CompileCommand`. pub struct CompileConfig { + /// Whether we are watching. + pub watching: bool, /// Path to input Typst file or stdin. pub input: Input, /// Path to output file (PDF, PNG, SVG, or HTML). @@ -64,11 +68,27 @@ pub struct CompileConfig { pub make_deps: Option, /// The PPI (pixels per inch) to use for PNG export. pub ppi: f32, + /// The export cache for images, used for caching output files in `typst + /// watch` sessions with images. + pub export_cache: ExportCache, + /// Server for `typst watch` to HTML. + pub server: Option, } impl CompileConfig { /// Preprocess a `CompileCommand`, producing a compilation config. - pub fn new(args: &CompileArgs) -> StrResult { + pub fn new(command: &CompileCommand) -> StrResult { + Self::new_impl(&command.args, None) + } + + /// Preprocess a `WatchCommand`, producing a compilation config. + pub fn watching(command: &WatchCommand) -> StrResult { + Self::new_impl(&command.args, Some(command)) + } + + /// The shared implementation of [`CompileConfig::new`] and + /// [`CompileConfig::watching`]. + fn new_impl(args: &CompileArgs, watch: Option<&WatchCommand>) -> StrResult { let input = args.input.clone(); let output_format = if let Some(specified) = args.format { @@ -119,7 +139,17 @@ impl CompileConfig { PdfStandards::new(&list)? }; + let mut server = None; + let mut watching = false; + if let Some(command) = watch { + watching = true; + if output_format == OutputFormat::Html && !command.no_serve { + server = Some(HtmlServer::new(&input, command.port, !command.no_reload)?); + } + } + Ok(Self { + watching, input, output, output_format, @@ -130,6 +160,8 @@ impl CompileConfig { ppi: args.ppi, diagnostic_format: args.process.diagnostic_format, open: args.open.clone(), + export_cache: ExportCache::new(), + server, }) } } @@ -141,21 +173,20 @@ impl CompileConfig { pub fn compile_once( world: &mut SystemWorld, config: &mut CompileConfig, - watching: bool, ) -> StrResult<()> { let start = std::time::Instant::now(); - if watching { + if config.watching { Status::Compiling.print(config).unwrap(); } - let Warned { output, warnings } = compile_and_export(world, config, watching); + let Warned { output, warnings } = compile_and_export(world, config); match output { // Export the PDF / PNG. Ok(()) => { let duration = start.elapsed(); - if watching { + if config.watching { if warnings.is_empty() { Status::Success(duration).print(config).unwrap(); } else { @@ -167,19 +198,14 @@ pub fn compile_once( .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; write_make_deps(world, config)?; - - if let Some(open) = config.open.take() { - if let Output::Path(file) = &config.output { - open_file(open.as_deref(), file)?; - } - } + open_output(config)?; } // Print diagnostics. Err(errors) => { set_failed(); - if watching { + if config.watching { Status::Error.print(config).unwrap(); } @@ -191,48 +217,48 @@ pub fn compile_once( Ok(()) } +/// Compile and then export the document. fn compile_and_export( world: &mut SystemWorld, config: &mut CompileConfig, - watching: bool, ) -> Warned> { match config.output_format { OutputFormat::Html => { let Warned { output, warnings } = typst::compile::(world); - let result = output.and_then(|document| { - config - .output - .write(typst_html::html(&document)?.as_bytes()) - .map_err(|err| eco_format!("failed to write HTML file ({err})")) - .at(Span::detached()) - }); + let result = output.and_then(|document| export_html(&document, config)); Warned { output: result, warnings } } _ => { let Warned { output, warnings } = typst::compile::(world); - let result = output - .and_then(|document| export_paged(world, &document, config, watching)); + let result = output.and_then(|document| export_paged(&document, config)); Warned { output: result, warnings } } } } -/// Export into the target format. -fn export_paged( - world: &mut SystemWorld, - document: &PagedDocument, - config: &CompileConfig, - watching: bool, -) -> SourceResult<()> { +/// Export to HTML. +fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult<()> { + let html = typst_html::html(document)?; + let result = config.output.write(html.as_bytes()); + + if let Some(server) = &config.server { + server.update(html); + } + + result + .map_err(|err| eco_format!("failed to write HTML file ({err})")) + .at(Span::detached()) +} + +/// Export to a paged target format. +fn export_paged(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> { match config.output_format { OutputFormat::Pdf => export_pdf(document, config), OutputFormat::Png => { - export_image(world, document, config, watching, ImageExportFormat::Png) - .at(Span::detached()) + export_image(document, config, ImageExportFormat::Png).at(Span::detached()) } OutputFormat::Svg => { - export_image(world, document, config, watching, ImageExportFormat::Svg) - .at(Span::detached()) + export_image(document, config, ImageExportFormat::Svg).at(Span::detached()) } OutputFormat::Html => unreachable!(), } @@ -278,10 +304,8 @@ enum ImageExportFormat { /// Export to one or multiple images. fn export_image( - world: &mut SystemWorld, document: &PagedDocument, config: &CompileConfig, - watching: bool, fmt: ImageExportFormat, ) -> StrResult<()> { // Determine whether we have indexable templates in output @@ -313,8 +337,6 @@ fn export_image( bail!("cannot export multiple images {err}"); } - let cache = world.export_cache(); - // The results are collected in a `Vec<()>` which does not allocate. exported_pages .par_iter() @@ -337,7 +359,10 @@ fn export_image( // If we are not watching, don't use the cache. // If the frame is in the cache, skip it. // If the file does not exist, always create it. - if watching && cache.is_cached(*i, &page.frame) && path.exists() { + if config.watching + && config.export_cache.is_cached(*i, &page.frame) + && path.exists() + { return Ok(()); } @@ -531,17 +556,26 @@ fn write_make_deps(world: &mut SystemWorld, config: &CompileConfig) -> StrResult }) } -/// Opens the given file using: +/// Opens the output if desired, with: /// - The default file viewer if `open` is `None`. /// - The given viewer provided by `open` if it is `Some`. /// /// If the file could not be opened, an error is returned. -fn open_file(open: Option<&str>, path: &Path) -> StrResult<()> { - // Some resource openers require the path to be canonicalized. - let path = path - .canonicalize() - .map_err(|err| eco_format!("failed to canonicalize path ({err})"))?; - if let Some(app) = open { +fn open_output(config: &mut CompileConfig) -> StrResult<()> { + let Some(open) = config.open.take() else { return Ok(()) }; + + let path = if let Some(server) = &config.server { + OsString::from(format!("http://{}", server.addr())) + } else if let Output::Path(path) = &config.output { + // Some resource openers require the path to be canonicalized. + path.canonicalize() + .map_err(|err| eco_format!("failed to canonicalize path ({err})"))? + .into_os_string() + } else { + return Ok(()); + }; + + if let Some(app) = &open { open::with_detached(&path, app) .map_err(|err| eco_format!("failed to open file with {} ({})", app, err)) } else { diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs index 8cef14157..610f89c03 100644 --- a/crates/typst-cli/src/main.rs +++ b/crates/typst-cli/src/main.rs @@ -6,6 +6,7 @@ mod greet; mod init; mod package; mod query; +mod server; mod terminal; mod timings; #[cfg(feature = "self-update")] diff --git a/crates/typst-cli/src/server.rs b/crates/typst-cli/src/server.rs new file mode 100644 index 000000000..b3ce83f86 --- /dev/null +++ b/crates/typst-cli/src/server.rs @@ -0,0 +1,217 @@ +use std::io::{self, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; +use std::sync::Arc; + +use ecow::eco_format; +use parking_lot::{Condvar, Mutex, MutexGuard}; +use tiny_http::{Header, Request, Response, StatusCode}; +use typst::diag::{bail, StrResult}; + +use crate::args::Input; + +/// Serves HTML with live reload. +pub struct HtmlServer { + addr: SocketAddr, + bucket: Arc>, +} + +impl HtmlServer { + /// Create a new HTTP server that serves live HTML. + pub fn new(input: &Input, port: Option, reload: bool) -> StrResult { + let (addr, server) = start_server(port)?; + + let placeholder = PLACEHOLDER_HTML.replace("{INPUT}", &input.to_string()); + let bucket = Arc::new(Bucket::new(placeholder)); + let bucket2 = bucket.clone(); + + std::thread::spawn(move || { + for req in server.incoming_requests() { + let _ = handle(req, reload, &bucket2); + } + }); + + Ok(Self { addr, bucket }) + } + + /// The address that we serve the HTML on. + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Updates the HTML, triggering a reload all connected browsers. + pub fn update(&self, html: String) { + self.bucket.put(html); + } +} + +/// Starts a local HTTP server. +/// +/// Uses the specified port or tries to find a free port in the range +/// `3000..=3005`. +fn start_server(port: Option) -> StrResult<(SocketAddr, tiny_http::Server)> { + const BASE_PORT: u16 = 3000; + + let mut addr; + let mut retries = 0; + + let listener = loop { + addr = SocketAddr::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + port.unwrap_or(BASE_PORT + retries), + ); + + match TcpListener::bind(addr) { + Ok(listener) => break listener, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + if let Some(port) = port { + bail!("port {port} is already in use") + } else if retries < 5 { + // If the port is in use, try the next one. + retries += 1; + } else { + bail!("could not find free port for HTTP server"); + } + } + Err(err) => bail!("failed to start TCP server: {err}"), + } + }; + + let server = tiny_http::Server::from_listener(listener, None) + .map_err(|err| eco_format!("failed to start HTTP server: {err}"))?; + + Ok((addr, server)) +} + +/// Handles a request. +fn handle(req: Request, reload: bool, bucket: &Arc>) -> io::Result<()> { + let path = req.url(); + match path { + "/" => handle_root(req, reload, bucket), + "/events" => handle_events(req, bucket.clone()), + _ => req.respond(Response::new_empty(StatusCode(404))), + } +} + +/// Handles for the `/` route. Serves the compiled HTML. +fn handle_root(req: Request, reload: bool, bucket: &Bucket) -> io::Result<()> { + let mut html = bucket.get().clone(); + if reload { + inject_live_reload_script(&mut html); + } + req.respond(Response::new( + StatusCode(200), + vec![Header::from_bytes("Content-Type", "text/html").unwrap()], + html.as_bytes(), + Some(html.len()), + None, + )) +} + +/// Handler for the `/events` route. +fn handle_events(req: Request, bucket: Arc>) -> io::Result<()> { + std::thread::spawn(move || { + // When this returns an error, the client is disconnected and we can + // terminate the thread. + let _ = handle_events_blocking(req, &bucket); + }); + Ok(()) +} + +/// Event stream for the `/events` route. +fn handle_events_blocking(req: Request, bucket: &Bucket) -> io::Result<()> { + let mut writer = req.into_writer(); + let writer: &mut dyn Write = &mut *writer; + + // We need to write the header manually because `tiny-http` defaults to + // `Transfer-Encoding: chunked` when no `Content-Length` is provided, which + // Chrome & Safari dislike for `Content-Type: text/event-stream`. + write!(writer, "HTTP/1.1 200 OK\r\n")?; + write!(writer, "Content-Type: text/event-stream\r\n")?; + write!(writer, "Cache-Control: no-cache\r\n")?; + write!(writer, "\r\n")?; + writer.flush()?; + + // If the user closes the browser tab, this loop will terminate once it + // tries to write to the dead socket for the first time. + loop { + bucket.wait(); + // Trigger a server-sent event. The browser is listening to it via + // an `EventSource` listener` (see `inject_script`). + write!(writer, "event: reload\ndata:\n\n")?; + writer.flush()?; + } +} + +/// Injects the live reload script into a string of HTML. +fn inject_live_reload_script(html: &mut String) { + let pos = html.rfind("").unwrap_or(html.len()); + html.insert_str(pos, LIVE_RELOAD_SCRIPT); +} + +/// Holds data and notifies consumers when it's updated. +struct Bucket { + mutex: Mutex, + condvar: Condvar, +} + +impl Bucket { + /// Creates a new bucket with initial data. + fn new(init: T) -> Self { + Self { mutex: Mutex::new(init), condvar: Condvar::new() } + } + + /// Retrieves the current data in the bucket. + fn get(&self) -> MutexGuard { + self.mutex.lock() + } + + /// Puts new data into the bucket and notifies everyone who's currently + /// [waiting](Self::wait). + fn put(&self, data: T) { + *self.mutex.lock() = data; + self.condvar.notify_all(); + } + + /// Waits for new data in the bucket. + fn wait(&self) { + self.condvar.wait(&mut self.mutex.lock()); + } +} + +/// The initial HTML before compilation is finished. +const PLACEHOLDER_HTML: &str = "\ + + + Waiting for {INPUT} + + + +
+
Waiting for output ...
+
typst watch {INPUT}
+
+ + +"; + +/// Reloads the page whenever it receives a "reload" server-sent event +/// on the `/events` route. +const LIVE_RELOAD_SCRIPT: &str = "\ +\ +"; diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index f5569b466..e62746dfb 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -21,7 +21,7 @@ use crate::{print_error, terminal}; /// Execute a watching compilation command. pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> { - let mut config = CompileConfig::new(&command.args)?; + let mut config = CompileConfig::watching(command)?; let Output::Path(output) = &config.output else { bail!("cannot write document to stdout in watch mode"); @@ -53,7 +53,7 @@ pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> { }; // Perform initial compilation. - timer.record(&mut world, |world| compile_once(world, &mut config, true))??; + timer.record(&mut world, |world| compile_once(world, &mut config))??; // Watch all dependencies of the initial compilation. watcher.update(world.dependencies())?; @@ -67,7 +67,7 @@ pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> { world.reset(); // Recompile. - timer.record(&mut world, |world| compile_once(world, &mut config, true))??; + timer.record(&mut world, |world| compile_once(world, &mut config))??; // Evict the cache. comemo::evict(10); @@ -293,6 +293,13 @@ impl Status { out.reset()?; writeln!(out, " {}", config.output)?; + if let Some(server) = &config.server { + out.set_color(&color)?; + write!(out, "serving at")?; + out.reset()?; + writeln!(out, " http://{}", server.addr())?; + } + writeln!(out)?; writeln!(out, "[{timestamp}] {}", self.message())?; writeln!(out)?; diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index c39358b9c..af6cf228f 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -18,7 +18,6 @@ use typst_kit::package::PackageStorage; use typst_timing::timed; use crate::args::{Feature, Input, ProcessArgs, WorldArgs}; -use crate::compile::ExportCache; use crate::download::PrintDownload; use crate::package; @@ -49,9 +48,6 @@ pub struct SystemWorld { /// always the same within one compilation. /// Reset between compilations if not [`Now::Fixed`]. now: Now, - /// The export cache, used for caching output files in `typst watch` - /// sessions. - export_cache: ExportCache, } impl SystemWorld { @@ -146,7 +142,6 @@ impl SystemWorld { slots: Mutex::new(HashMap::new()), package_storage: package::storage(&world_args.package), now, - export_cache: ExportCache::new(), }) } @@ -191,11 +186,6 @@ impl SystemWorld { pub fn lookup(&self, id: FileId) -> Source { self.source(id).expect("file id does not point to any source file") } - - /// Gets access to the export cache. - pub fn export_cache(&self) -> &ExportCache { - &self.export_cache - } } impl World for SystemWorld {