Live reloading HTTP server for typst watch and HTML export (#5524)

This commit is contained in:
Laurenz 2024-12-05 10:59:26 +01:00 committed by GitHub
parent 4f3ba7f8ca
commit 79a7a6bf77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 360 additions and 64 deletions

36
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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 }

View File

@ -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<u16>,
}
/// Initializes a new project from a template.

View File

@ -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<PathBuf>,
/// 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<HtmlServer>,
}
impl CompileConfig {
/// Preprocess a `CompileCommand`, producing a compilation config.
pub fn new(args: &CompileArgs) -> StrResult<Self> {
pub fn new(command: &CompileCommand) -> StrResult<Self> {
Self::new_impl(&command.args, None)
}
/// Preprocess a `WatchCommand`, producing a compilation config.
pub fn watching(command: &WatchCommand) -> StrResult<Self> {
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<Self> {
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<SourceResult<()>> {
match config.output_format {
OutputFormat::Html => {
let Warned { output, warnings } = typst::compile::<HtmlDocument>(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::<PagedDocument>(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 {

View File

@ -6,6 +6,7 @@ mod greet;
mod init;
mod package;
mod query;
mod server;
mod terminal;
mod timings;
#[cfg(feature = "self-update")]

View File

@ -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<Bucket<String>>,
}
impl HtmlServer {
/// Create a new HTTP server that serves live HTML.
pub fn new(input: &Input, port: Option<u16>, reload: bool) -> StrResult<Self> {
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<u16>) -> 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<Bucket<String>>) -> 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<String>) -> 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<Bucket<String>>) -> 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<String>) -> 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("</html>").unwrap_or(html.len());
html.insert_str(pos, LIVE_RELOAD_SCRIPT);
}
/// Holds data and notifies consumers when it's updated.
struct Bucket<T> {
mutex: Mutex<T>,
condvar: Condvar,
}
impl<T> Bucket<T> {
/// 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<T> {
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 = "\
<html>
<head>
<title>Waiting for {INPUT}</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
color: #565565;
background: #eff0f3;
}
body > main > div {
margin-block: 16px;
text-align: center;
}
</style>
</head>
<body>
<main>
<div>Waiting for output ...</div>
<div><code>typst watch {INPUT}</code></div>
</main>
</body>
</html>
";
/// Reloads the page whenever it receives a "reload" server-sent event
/// on the `/events` route.
const LIVE_RELOAD_SCRIPT: &str = "\
<script>\
new EventSource(\"/events\")\
.addEventListener(\"reload\", () => location.reload())\
</script>\
";

View File

@ -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)?;

View File

@ -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 {