use std::fmt::Write as _;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use notify::RecommendedWatcher;
use tiny_http::{Header, Request, Response, Server};
#[cfg(unix)]
use crate::control;
use crate::{render, watch};
const SHELL_HTML: &str = include_str!("../assets/shell.html");
const APP_CSS: &str = include_str!("../assets/app.css");
const APP_JS: &str = include_str!("../assets/app.js");
const GITHUB_MARKDOWN_CSS: &str = include_str!("../assets/vendor/github-markdown.css");
const MERMAID_JS: &[u8] = include_bytes!("../assets/vendor/mermaid.min.js");
const SSE_HEARTBEAT: Duration = Duration::from_secs(10);
const PROBE_GAP: Duration = Duration::from_millis(25);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
Reload,
Scroll(u32),
Ping,
}
fn sse_frame(event: Event) -> String {
match event {
Event::Reload => "event: reload\ndata:\n\n".to_owned(),
Event::Scroll(line) => format!("event: scroll\ndata: {line}\n\n"),
Event::Ping => ": ping\n\n".to_owned(),
}
}
pub struct Config {
pub url: String,
pub idle_grace: Option<Duration>,
#[cfg(unix)]
pub control: Option<control::ControlSocket>,
#[cfg(unix)]
pub exit_on_quit: bool,
}
type Clients = Arc<Mutex<Vec<Sender<Event>>>>;
struct Current {
path: PathBuf,
_watcher: Option<RecommendedWatcher>,
}
#[derive(Clone)]
struct State {
current: Arc<Mutex<Current>>,
events_tx: Sender<Event>,
clients: Clients,
active: Arc<AtomicUsize>,
ever_connected: Arc<AtomicBool>,
opened: Arc<AtomicBool>,
}
pub fn serve(server: Server, file: PathBuf, config: Config) {
let (events_tx, events_rx) = channel::<Event>();
let watcher = start_watch(&file, &events_tx);
let state = State {
current: Arc::new(Mutex::new(Current {
path: file,
_watcher: watcher,
})),
events_tx,
clients: Arc::new(Mutex::new(Vec::new())),
active: Arc::new(AtomicUsize::new(0)),
ever_connected: Arc::new(AtomicBool::new(false)),
opened: Arc::new(AtomicBool::new(false)),
};
spawn_dispatcher(events_rx, Arc::clone(&state.clients));
#[cfg(unix)]
let socket = config.control.map(
|control::ControlSocket {
listener,
path,
inode,
}| {
let handler_state = state.clone();
let url = config.url.clone();
let quit_socket = (path.clone(), inode);
let exit_on_quit = config.exit_on_quit;
control::spawn_listener(
listener,
move |open| handler_state.open(open, &url),
move || {
if exit_on_quit {
let (path, inode) = &quit_socket;
control::remove_socket_if_ours(path, *inode);
process::exit(0);
}
},
);
(path, inode)
},
);
if let Some(grace) = config.idle_grace {
spawn_monitor(
Arc::clone(&state.active),
Arc::clone(&state.ever_connected),
Arc::clone(&state.opened),
grace,
move || {
#[cfg(unix)]
if let Some((path, inode)) = &socket {
control::remove_socket_if_ours(path, *inode);
}
},
);
}
for request in server.incoming_requests() {
let state = state.clone();
thread::spawn(move || handle(request, &state));
}
}
impl State {
#[cfg(unix)]
fn open(&self, open: control::Open, url: &str) -> control::Reply {
let control::Open { path, line } = open;
if !path.is_absolute() {
return control::Reply::Err(format!("not an absolute path: {}", path.display()));
}
if !path.is_file() {
return control::Reply::Err(format!("not a file: {}", path.display()));
}
self.opened.store(true, Ordering::Relaxed);
{
let mut current = self.current.lock().unwrap();
if current.path != path {
let watcher = start_watch(&path, &self.events_tx);
*current = Current {
path,
_watcher: watcher,
};
let _ = self.events_tx.send(Event::Reload);
}
}
if let Some(line) = line {
let _ = self.events_tx.send(Event::Scroll(line));
}
control::Reply::Ok {
url: url.to_owned(),
clients: self.live_clients(),
}
}
#[cfg(unix)]
fn live_clients(&self) -> usize {
for _ in 0..2 {
if self.active.load(Ordering::Relaxed) == 0 {
break;
}
let _ = self.events_tx.send(Event::Ping);
thread::sleep(PROBE_GAP);
}
self.active.load(Ordering::Relaxed)
}
}
fn start_watch(path: &Path, events_tx: &Sender<Event>) -> Option<RecommendedWatcher> {
match watch::watch_file(path, events_tx.clone()) {
Ok(watcher) => Some(watcher),
Err(err) => {
eprintln!("mdpreviewer: file watch failed, live reload disabled: {err}");
None
}
}
}
fn spawn_dispatcher(events_rx: Receiver<Event>, clients: Clients) {
thread::spawn(move || {
while let Ok(event) = events_rx.recv() {
let mut guard = clients.lock().unwrap();
guard.retain(|tx| tx.send(event).is_ok());
}
});
}
fn spawn_monitor(
active: Arc<AtomicUsize>,
ever_connected: Arc<AtomicBool>,
opened: Arc<AtomicBool>,
grace: Duration,
on_exit: impl Fn() + Send + 'static,
) {
const STEP: Duration = Duration::from_secs(3);
thread::spawn(move || {
let mut clock = IdleClock::new(grace);
loop {
thread::sleep(STEP);
let armed = ever_connected.load(Ordering::Relaxed);
let active = active.load(Ordering::Relaxed);
let opened = opened.swap(false, Ordering::Relaxed);
if clock.tick(STEP, armed, active, opened) {
on_exit();
process::exit(0);
}
}
});
}
struct IdleClock {
grace: Duration,
idle: Duration,
}
impl IdleClock {
fn new(grace: Duration) -> Self {
IdleClock {
grace,
idle: Duration::ZERO,
}
}
fn tick(&mut self, step: Duration, armed: bool, active: usize, opened: bool) -> bool {
if armed && active == 0 && !opened {
self.idle += step;
self.idle >= self.grace
} else {
self.idle = Duration::ZERO;
false
}
}
}
fn handle(request: Request, state: &State) {
let path = request.url().split('?').next().unwrap_or("/");
match path {
"/" => respond(request, SHELL_HTML.as_bytes(), "text/html; charset=utf-8"),
"/content" => serve_content(request, state),
"/events" => serve_events(request, state),
"/assets/app.css" => respond(request, APP_CSS.as_bytes(), "text/css; charset=utf-8"),
"/assets/app.js" => respond(request, APP_JS.as_bytes(), "text/javascript; charset=utf-8"),
"/assets/github-markdown.css" => respond(
request,
GITHUB_MARKDOWN_CSS.as_bytes(),
"text/css; charset=utf-8",
),
"/assets/mermaid.min.js" => respond(request, MERMAID_JS, "text/javascript; charset=utf-8"),
_ => {
let response = Response::from_data(&b"not found"[..]).with_status_code(404);
let _ = request.respond(response);
}
}
}
fn serve_content(request: Request, state: &State) {
let path = state.current.lock().unwrap().path.clone();
let body = match fs::read_to_string(&path) {
Ok(markdown) => render::render_markdown(&markdown),
Err(err) => format!(
"<h1>mdpreviewer</h1><p>Could not read <code>{}</code>: {}</p>",
path.display(),
err
),
};
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let response = Response::from_data(body.into_bytes())
.with_header(header("Content-Type", "text/html; charset=utf-8"))
.with_header(header("X-Mdpreviewer-File", &percent_encode(&name)));
let _ = request.respond(response);
}
fn percent_encode(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for byte in name.bytes() {
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
out.push(char::from(byte));
} else {
let _ = write!(out, "%{byte:02X}");
}
}
out
}
fn respond(request: Request, body: &[u8], content_type: &str) {
let response =
Response::from_data(body.to_vec()).with_header(header("Content-Type", content_type));
let _ = request.respond(response);
}
fn header(name: &str, value: &str) -> Header {
Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid header")
}
fn serve_events(request: Request, state: &State) {
let (tx, rx) = channel::<Event>();
state.clients.lock().unwrap().push(tx);
state.ever_connected.store(true, Ordering::Relaxed);
let _guard = ActiveGuard::new(Arc::clone(&state.active));
let mut writer = request.into_writer();
let head = "HTTP/1.1 200 OK\r\n\
Content-Type: text/event-stream\r\n\
Cache-Control: no-cache\r\n\
Connection: keep-alive\r\n\
\r\n";
if write_flush(&mut writer, head.as_bytes()).is_err() {
return;
}
if write_flush(&mut writer, b": connected\n\n").is_err() {
return;
}
loop {
let frame = match rx.recv_timeout(SSE_HEARTBEAT) {
Ok(event) => sse_frame(event),
Err(RecvTimeoutError::Timeout) => sse_frame(Event::Ping),
Err(RecvTimeoutError::Disconnected) => break,
};
if write_flush(&mut writer, frame.as_bytes()).is_err() {
break;
}
}
}
fn write_flush(writer: &mut dyn Write, bytes: &[u8]) -> std::io::Result<()> {
writer.write_all(bytes)?;
writer.flush()
}
struct ActiveGuard(Arc<AtomicUsize>);
impl ActiveGuard {
fn new(active: Arc<AtomicUsize>) -> Self {
active.fetch_add(1, Ordering::Relaxed);
ActiveGuard(active)
}
}
impl Drop for ActiveGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{Event, IdleClock, percent_encode, sse_frame};
const STEP: Duration = Duration::from_secs(3);
#[test]
fn the_server_idles_out_after_the_grace_period() {
let mut clock = IdleClock::new(Duration::from_secs(15));
assert!(!clock.tick(STEP * 10, false, 0, false));
for _ in 0..4 {
assert!(!clock.tick(STEP, true, 0, false));
}
assert!(clock.tick(STEP, true, 0, false));
}
#[test]
fn an_open_request_restarts_the_idle_countdown() {
let mut clock = IdleClock::new(Duration::from_secs(15));
for _ in 0..4 {
assert!(!clock.tick(STEP, true, 0, false));
}
assert!(!clock.tick(STEP, true, 0, true));
for _ in 0..4 {
assert!(!clock.tick(STEP, true, 0, false));
}
assert!(clock.tick(STEP, true, 0, false));
}
#[test]
fn reload_is_a_named_event_with_empty_data() {
assert_eq!(sse_frame(Event::Reload), "event: reload\ndata:\n\n");
}
#[test]
fn scroll_carries_the_line() {
assert_eq!(sse_frame(Event::Scroll(42)), "event: scroll\ndata: 42\n\n");
}
#[test]
fn file_names_are_percent_encoded_for_the_header() {
assert_eq!(percent_encode("notes-2026_v1.md"), "notes-2026_v1.md");
assert_eq!(percent_encode("café notes.md"), "caf%C3%A9%20notes.md");
}
}
#[cfg(all(test, unix))]
mod integration_tests {
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use super::{Config, serve};
use crate::control::{self, Reply, Request};
use crate::testutil::TestDir;
const WAIT: Duration = Duration::from_secs(5);
struct Preview {
http: SocketAddr,
socket: PathBuf,
}
fn start(dir: &TestDir, file: &Path) -> Preview {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let http = listener.local_addr().unwrap();
let socket = dir.join("mdpreviewer.sock");
let config = Config {
url: format!("http://{http}/"),
idle_grace: None,
control: Some(control::bind(&socket).unwrap()),
exit_on_quit: false,
};
let server = tiny_http::Server::from_listener(listener, None).unwrap();
let file = file.to_owned();
thread::spawn(move || serve(server, file, config));
Preview { http, socket }
}
fn open(preview: &Preview, path: &Path, line: Option<u32>) -> Reply {
let request = Request::Open(control::Open {
path: path.to_owned(),
line,
});
control::send(&preview.socket, &request, WAIT).unwrap()
}
fn get(preview: &Preview, path: &str) -> String {
let mut stream = TcpStream::connect(preview.http).unwrap();
stream.set_read_timeout(Some(WAIT)).unwrap();
write!(
stream,
"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
)
.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
response
}
struct Events {
stream: TcpStream,
buf: String,
}
impl Events {
fn connect(preview: &Preview) -> Self {
let mut stream = TcpStream::connect(preview.http).unwrap();
stream.set_read_timeout(Some(WAIT)).unwrap();
write!(stream, "GET /events HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
let mut events = Events {
stream,
buf: String::new(),
};
while !events.buf.contains(": connected\n\n") {
events.fill();
}
let end = events.buf.find(": connected\n\n").unwrap() + ": connected\n\n".len();
events.buf.drain(..end);
events
}
fn fill(&mut self) {
let mut chunk = [0u8; 1024];
let n = self
.stream
.read(&mut chunk)
.expect("timed out waiting for an event");
assert!(n > 0, "event stream closed");
self.buf.push_str(std::str::from_utf8(&chunk[..n]).unwrap());
}
fn next(&mut self) -> String {
loop {
if let Some(end) = self.buf.find("\n\n") {
let frame: String = self.buf.drain(..end + 2).collect();
if !frame.starts_with(':') {
return frame;
}
} else {
self.fill();
}
}
}
}
#[test]
fn opening_the_current_file_scrolls_without_reloading() {
let dir = TestDir::new("same-file");
let a = dir.join("a.md");
fs::write(&a, "# A\n").unwrap();
let preview = start(&dir, &a);
let mut events = Events::connect(&preview);
let reply = open(&preview, &a, Some(3));
assert_eq!(
reply,
Reply::Ok {
url: format!("http://{}/", preview.http),
clients: 1
}
);
assert_eq!(events.next(), "event: scroll\ndata: 3\n\n");
assert!(matches!(open(&preview, &a, None), Reply::Ok { .. }));
assert!(matches!(open(&preview, &a, Some(5)), Reply::Ok { .. }));
assert_eq!(events.next(), "event: scroll\ndata: 5\n\n");
}
#[test]
fn opening_another_file_switches_the_document_and_the_watch() {
let dir = TestDir::new("switch");
let a = dir.join("a.md");
let b = dir.join("b.md");
fs::write(&a, "# A\n").unwrap();
fs::write(&b, "# B\n").unwrap();
let preview = start(&dir, &a);
let mut events = Events::connect(&preview);
assert!(matches!(open(&preview, &b, Some(1)), Reply::Ok { .. }));
assert_eq!(events.next(), "event: reload\ndata:\n\n");
assert_eq!(events.next(), "event: scroll\ndata: 1\n\n");
let content = get(&preview, "/content");
assert!(
content
.to_ascii_lowercase()
.contains("x-mdpreviewer-file: b.md"),
"{content}"
);
assert!(
content.contains("<h1 data-sourcepos=\"1:1-1:3\">B</h1>"),
"{content}"
);
fs::write(&a, "# A changed\n").unwrap();
thread::sleep(Duration::from_millis(500));
assert!(matches!(open(&preview, &b, Some(9)), Reply::Ok { .. }));
assert_eq!(events.next(), "event: scroll\ndata: 9\n\n");
fs::write(&b, "# B changed\n").unwrap();
assert_eq!(events.next(), "event: reload\ndata:\n\n");
}
#[test]
fn a_closed_tab_is_not_counted_as_a_client() {
let dir = TestDir::new("closed-tab");
let a = dir.join("a.md");
fs::write(&a, "# A\n").unwrap();
let preview = start(&dir, &a);
let events = Events::connect(&preview);
drop(events);
match open(&preview, &a, None) {
Reply::Ok { clients, .. } => assert_eq!(clients, 0),
reply => panic!("expected ok, got {reply:?}"),
}
}
#[test]
fn opening_a_missing_file_is_an_error() {
let dir = TestDir::new("missing");
let a = dir.join("a.md");
fs::write(&a, "# A\n").unwrap();
let preview = start(&dir, &a);
match open(&preview, &dir.join("nope.md"), None) {
Reply::Err(reason) => assert!(reason.starts_with("not a file"), "{reason}"),
reply => panic!("expected an error, got {reply:?}"),
}
}
#[test]
fn malformed_control_requests_get_an_error_reply() {
let dir = TestDir::new("malformed-server");
let a = dir.join("a.md");
fs::write(&a, "# A\n").unwrap();
let preview = start(&dir, &a);
let mut stream = UnixStream::connect(&preview.socket).unwrap();
stream.set_read_timeout(Some(WAIT)).unwrap();
stream.write_all(b"open\trelative.md\n").unwrap();
let mut reply = String::new();
BufReader::new(stream).read_line(&mut reply).unwrap();
assert!(reply.starts_with("err\t"), "{reply:?}");
}
}