Skip to main content

web_vlog/
lib.rs

1//! `web-vlog` implements `v-log` with the goal of being feature complete but minimal in size.
2//! This goal is achieved by offloading the drawing to a webbrowser. The webpage is served
3//! exactly once before changing to a websocket connection, which handles the potentially
4//! high datarates. This setup doesn't have the performance of a direct GPU renderer, but
5//! it has decent performance at very little compiletime and runtime cost for the vlogging
6//! process itself.
7//!
8//! The webpage uses SVG to render the vlogging surfaces and provides clickable links
9//! to open the relevant lines in VSCode.
10//!
11//! This crate depends on `sha1` and `base64` due to the websocket handshake, which requires both.
12//! **Nothing is encrypted, as this is a debug utility, which should not be shipped in production code.**
13//!
14//! # Usage
15//!
16//! ```
17//! use v_log::message;
18//!
19//! // Initialize the vlogger on any free port.
20//! // This should be done as early as possible in the binary.
21//! let port = web_vlog::init();
22//! println!("Listening on port {port}");
23//!
24//! // Now we need a webbrowser to connect to the port.
25//! // This can be accelerated using the `open` crate.
26//! let _ = open::that(format!("http://localhost:{port}/"));
27//!
28//! // wait for a webbrowser to connect to the port.
29//! web_vlog::wait_for_connection();
30//!
31//! message!(target: "custom_target_1", "surface", "First message");
32//! message!(target: "custom_target_2", "surface", "Second message");
33//! message!(target: "custom_target_2::submodule", "surface", "Third message");
34//! # std::thread::sleep(std::time::Duration::from_millis(100));
35//! ```
36//!
37//! When called without environment variables, all 3 messages will be logged.
38//! Using the environment variable `RUST_VLOG` it is possible to filter by target prefixes.
39//! The environment variable is interpreted as a comma separated list of target prefix filters.
40//! Each filter, allows all targets which start with it to be vlogged. In our example
41//! above, running it with
42//! ```cmd
43//! $ RUST_VLOG=custom_target_1 ./main
44//! ```
45//! would only produce the message "First message". When instead the second target is specified
46//! ```cmd
47//! $ RUST_VLOG=custom_target_2 cargo run
48//! ```
49//! the output is "Second message" and "Third message". This is due to the filter being a prefix filter.
50//! Executing the executable directly with an environment variable, and executing using
51//! `cargo run` both work. This way it is also possible to use filtering in tests using `RUST_VLOG=... cargo test`.
52//! Tests in a library should only use a vlogger implementation as dev-dependency.
53//!
54//! The target filters can also be chosen in the programm using the [`Builder`] to initialize the [`WebVLogger`].
55//! That would be done using the following code:
56//! ```
57//! // Init a vlogger on port 1234, ignoring the environment variable and
58//! // choosing "custom_target_1" as an allowed prefix for the vlogger.
59//! web_vlog::Builder::new().port(1234).add_target("custom_target_1").init().unwrap();
60//! ```
61
62use base64::{prelude::BASE64_STANDARD, Engine};
63use sha1::Digest;
64use std::{
65    fmt::{self, Write as _},
66    io::{self, prelude::*, BufReader, BufWriter},
67    net::*,
68    sync::{
69        mpsc::{channel, Receiver, Sender},
70        Condvar, Mutex,
71    },
72    time::Duration,
73};
74use v_log::{Color, Record, SetVLoggerError, VLog, Visual};
75
76static WAIT: (Mutex<bool>, Condvar) = (Mutex::new(false), Condvar::new());
77
78/// A builder for [`WebVLogger`].
79pub struct Builder {
80    port: u16,
81    targets: Vec<String>,
82}
83/// A Vlogger implementation, which hosts a webpage for the visualisation.
84pub struct WebVLogger {
85    sender: Sender<String>,
86    targets: Vec<String>,
87}
88
89/// The error type returned by [`init`].
90///
91/// [`init`]: fn.init.html
92#[allow(missing_copy_implementations)]
93#[derive(Debug)]
94pub enum InitError {
95    SetVLoggerError(SetVLoggerError),
96    TcpError(io::Error),
97}
98
99impl fmt::Display for InitError {
100    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
101        match self {
102            Self::SetVLoggerError(e) => e.fmt(f),
103            Self::TcpError(e) => e.fmt(f),
104        }
105    }
106}
107
108impl std::error::Error for InitError {}
109
110impl From<SetVLoggerError> for InitError {
111    fn from(value: SetVLoggerError) -> Self {
112        Self::SetVLoggerError(value)
113    }
114}
115impl From<io::Error> for InitError {
116    fn from(value: io::Error) -> Self {
117        Self::TcpError(value)
118    }
119}
120
121impl Builder {
122    /// Create a new [`Builder`] for [`WebVLogger`] with
123    /// the default port `0`, which means the OS will choose the port.
124    pub fn new() -> Self {
125        Self {
126            port: 0,
127            targets: vec![],
128        }
129    }
130    /// Set the port on which the server will be made available.
131    ///
132    /// If set to 0, an available port will be choosen by the OS.
133    pub fn port(&mut self, port: u16) -> &mut Self {
134        self.port = port;
135        self
136    }
137    /// Add a target to the target whitelist.
138    /// If the whitelist is left empty, all targets are allowed.
139    pub fn add_target(&mut self, target: &str) -> &mut Self {
140        self.targets.push(target.to_owned());
141        self
142    }
143    /// Read the targets from the
144    pub fn targets_from_env(&mut self) -> &mut Self {
145        if let Ok(var) = std::env::var("RUST_VLOG") {
146            for target in var.split(",") {
147                let target = target.trim();
148                if !target.is_empty() {
149                    self.add_target(target);
150                }
151            }
152        }
153        self
154    }
155    /// Initialize the [`WebVLogger`] and set it as the global vlogger for [`v_log`].
156    ///
157    /// Returns the actual port, which the server runs on.
158    /// This is only relevant if the port was set to 0.
159    ///
160    /// # Errors
161    ///
162    /// If the global vlogger has already been set an [`InitError::SetVLoggerError`] is returned.
163    /// If the server could not be started on the chosen port, the [`std::io::Error`] is returned inside [`InitError::TcpError`].
164    pub fn init(&self) -> Result<u16, InitError> {
165        let port = self.port;
166        let (sender, rx) = channel();
167        let mut vlogger = WebVLogger {
168            sender,
169            targets: self.targets.clone(),
170        };
171        vlogger.targets.sort();
172        vlogger.targets.dedup();
173        // first try to set the vlogger.
174        v_log::set_boxed_vlogger(Box::new(vlogger))?;
175        // then try to open the port on localhost
176        // If this fails, the `rx` will be dropped.
177        // The vlogger will therefore stop.
178        let listener = TcpListener::bind(("localhost", port))?;
179        let addr = listener.local_addr()?;
180        log::info!("web-vlog server started on {addr}");
181        // If the vlogger is successfully set, start the webserver.
182        std::thread::spawn(move || {
183            server_loop(listener, rx);
184        });
185        if port != 0 {
186            assert_eq!(port, addr.port());
187        }
188        Ok(addr.port())
189    }
190}
191
192impl VLog for WebVLogger {
193    fn enabled(&self, metadata: &v_log::Metadata) -> bool {
194        self.targets.is_empty()
195            || self
196                .targets
197                .iter()
198                .any(|target| metadata.target().starts_with(target))
199    }
200    fn vlog(&self, record: &Record) {
201        if !self.enabled(record.metadata()) {
202            return;
203        }
204        // convert the record into a message to be send to the frontend.
205        let surface = record.surface().escape_default();
206        let size = record.size();
207        let color_meta = |start| {
208            let mut msg = format!(
209                "{start},\"meta\":{{\"target\":\"{}\",\"file\":\"{}/{}\",\"line\":{}}},\"col\":\"",
210                record.target().escape_default(),
211                env!("CARGO_MANIFEST_DIR").escape_default(),
212                record
213                    .file()
214                    .unwrap_or("")
215                    .trim_start_matches('.')
216                    .escape_default(),
217                record.line().unwrap_or(0),
218            );
219            match *record.color() {
220                Color::Base => msg.push_str("var(--base)\"}"),
221                Color::Healthy => msg.push_str("var(--healthy)\"}"),
222                Color::Error => msg.push_str("var(--error)\"}"),
223                Color::Warn => msg.push_str("var(--warn)\"}"),
224                Color::Info => msg.push_str("var(--info)\"}"),
225                Color::X => msg.push_str("var(--x)\"}"),
226                Color::Y => msg.push_str("var(--y)\"}"),
227                Color::Z => msg.push_str("var(--z)\"}"),
228                Color::Hex(hexcode) => write!(&mut msg, "#{hexcode:08X}\"}}").unwrap(),
229                _ => unimplemented!(),
230            }
231            msg
232        };
233        let mut tmp = String::new();
234        let label = record.args().as_str().map_or_else(
235            || {
236                tmp = record.args().to_string();
237                tmp.escape_default()
238            },
239            |s| s.escape_default(),
240        );
241        let msg = match record.visual() {
242            Visual::Message => {
243                color_meta(format_args!("{{\"msg\":\"{label}\",\"surf\":\"{surface}\""))
244            }
245            Visual::Label { x, y, z, alignment } => {
246                color_meta(format_args!("{{\"lbl\":\"{label}\",\"pos\":[{x},{y},{z}],\"align\":{},\"surf\":\"{surface}\",\"size\":{size}", *alignment as u8))
247            }
248            Visual::Point { x, y, z, style } => {
249                color_meta(format_args!("{{\"lbl\":\"{label}\",\"pos\":[{x},{y},{z}],\"style\":\"{style:?}\",\"surf\":\"{surface}\",\"size\":{size}"))
250            }
251            Visual::Line { x1, y1, z1, x2, y2, z2, style } => {
252                color_meta(format_args!("{{\"lbl\":\"{label}\",\"pos\":[{x1},{y1},{z1}],\"pos2\":[{x2},{y2},{z2}],\"style\":\"{style:?}\",\"surf\":\"{surface}\",\"size\":{size}"))
253            }
254        };
255        // If the receiver is dropped, the messages will still be constructed, but no longer sent.
256        // This case doesn't have to be optimized with an early return, as it's the error state.
257        let _ = self.sender.send(msg);
258    }
259    fn clear(&self, surface: &str) {
260        let _ = self.sender.send(format!(
261            "{{\"clear\":1,\"surf\":\"{}\"}}",
262            surface.escape_default()
263        ));
264    }
265}
266
267/// Initialise the vlogger with a custom port and otherwise default configuation.
268/// If the custom port is set to 0, a free port will be choosen by the OS and
269/// returned by this function. This function never panics.
270///
271/// Vlog messages will not be filtered.
272/// The `RUST_VLOG` environment variable is not used.
273pub fn init_port(port: u16) -> Result<u16, InitError> {
274    Builder::new().port(port).init()
275}
276
277/// Initialise the vlogger with the default configuation.
278/// The target whitelist gets loaded from the environment variable
279/// `RUST_VLOG`. If it is not set, all targets are whitelisted.
280///
281/// Returns the port at which the server is made available.
282///
283/// # Panics
284///
285/// This function will panic if the vlogger has already been
286/// set or the server could not be started. For a non panicking
287/// version see [`init_port`].
288pub fn init() -> u16 {
289    Builder::new().targets_from_env().init().unwrap()
290}
291
292/// Wait for a client to connect to the vlogging server.
293/// This blocks indefinitely if no server has been started.
294pub fn wait_for_connection() {
295    let lock = WAIT.0.lock().unwrap();
296    let _lock = WAIT.1.wait_while(lock, |v| !*v).unwrap();
297}
298/// Wait for the client to disconnect from the vlogging server.
299/// This can be used to ensure all messages have been received.
300pub fn wait_for_disconnect() {
301    let lock = WAIT.0.lock().unwrap();
302    let _lock = WAIT.1.wait_while(lock, |v| *v).unwrap();
303}
304/// Wait for the client to disconnect from the vlogging server.
305///
306/// Returns true on success and false if it timed out.
307pub fn wait_for_disconnect_timeout(dur: Duration) -> bool {
308    let lock = WAIT.0.lock().unwrap();
309    let lock = WAIT.1.wait_timeout_while(lock, dur, |v| *v).unwrap();
310    !lock.1.timed_out()
311}
312
313fn server_loop(listener: TcpListener, rx: Receiver<String>) {
314    // It's ok to panic in this thread to notify the user that something went wrong.
315    while let Ok((mut stream, addr)) = listener.accept() {
316        log::info!("vlogger connection from {addr}");
317        if let Err(err) = handle_connection(&stream, &rx) {
318            if let Err(err) = stream
319                .write_all(format!("HTTP/1.1 500 INTERNAL SERVER ERROR\r\n\r\n{err}").as_bytes())
320            {
321                log::error!("an error occurred: {err:?}");
322            }
323        }
324    }
325}
326
327fn handle_connection(stream: &TcpStream, rx: &Receiver<String>) -> std::io::Result<()> {
328    let mut buf_reader = BufReader::new(stream);
329    let mut buf_writer = BufWriter::new(stream);
330    // only use the first line
331    let mut buf = String::new();
332    let mut http_request = String::new();
333    let mut key_back = String::new();
334    while let Ok(bytes) = buf_reader.read_line(&mut buf) {
335        let l = buf.trim_end();
336        log::debug!("{l}");
337        if bytes == 0 || l.is_empty() {
338            break;
339        }
340        if http_request.is_empty() {
341            http_request.push_str(l);
342        }
343        // see https://datatracker.ietf.org/doc/html/rfc6455
344        else if let Some(key) = l.strip_prefix("Sec-WebSocket-Key: ") {
345            let key = key.to_owned() + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
346            let digest = sha1::Sha1::digest(key);
347            key_back = BASE64_STANDARD.encode(digest);
348        }
349        buf.clear();
350    }
351    let (get, rest) = http_request.split_once(' ').unwrap_or(("", ""));
352    let (path, http) = rest.split_once(' ').unwrap_or(("", ""));
353    if get == "GET" && http == "HTTP/1.1" {
354        if !key_back.is_empty() {
355            log::debug!("vlogging client connected");
356            {
357                let mut guard = WAIT.0.lock().unwrap();
358                *guard = true;
359                WAIT.1.notify_all();
360            }
361            buf_writer.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key_back}\r\n\r\n").as_bytes())?;
362            buf_writer.flush()?;
363            stream.set_nonblocking(true)?;
364            let close = |buf_writer: &mut BufWriter<&TcpStream>| {
365                // ignore IO errors here, as the condvar needs to be notified.
366                let _ = stream.set_nonblocking(false);
367                let _ = buf_writer.write_all(&[0x88, 0x80]);
368                let _ = buf_writer.flush();
369                log::info!("vlogger connection closed");
370                let mut guard = WAIT.0.lock().unwrap();
371                *guard = false;
372                WAIT.1.notify_all();
373                Ok(())
374            };
375            let mut byte_buf = [0u8; 64];
376            while let Ok(msg) = rx.recv() {
377                if msg.is_empty() {
378                    // this is a message to this thread, that the main thread has ended.
379                    // drop the connection to notify it that all messages have been written.
380                    return close(&mut buf_writer);
381                }
382                // first check if a socket close is received
383                while let Ok(bytes) = buf_reader.read(&mut byte_buf) {
384                    // don't parse it properly. Only ever expect close events to happen.
385                    // if bytes = 0, the connection has ended already without the closing message.
386                    if bytes == 0 || byte_buf[..bytes].iter().any(|b| *b == 0x88) {
387                        // close the connection correctly so the server can listen for a new connection.
388                        return close(&mut buf_writer);
389                    }
390                }
391                // send message
392                if msg.len() < 126 {
393                    buf_writer.write_all(&[0x81, msg.len() as u8])?;
394                    buf_writer.write_all(msg.as_bytes())?;
395                } else if msg.len() <= u16::MAX as usize {
396                    buf_writer.write_all(&[0x81, 126])?;
397                    buf_writer.write_all(&(msg.len() as u16).to_be_bytes())?;
398                    buf_writer.write_all(msg.as_bytes())?;
399                } else {
400                    buf_writer.write_all(&[0x81, 127])?;
401                    buf_writer.write_all(&(msg.len() as u64).to_be_bytes())?;
402                    buf_writer.write_all(msg.as_bytes())?;
403                }
404                buf_writer.flush()?;
405            }
406        } else if path == "/" {
407            buf_writer.write_all("HTTP/1.1 200 OK\r\n\r\n".as_bytes())?;
408            buf_writer.write_all(include_bytes!("site.html"))?;
409        } else {
410            buf_writer.write_all(
411                "HTTP/1.1 404 NOT FOUND\r\n\r\n<html><body>Path not found</body></html>".as_bytes(),
412            )?;
413        }
414    } else {
415        buf_writer.write_all("HTTP/1.1 400 BAD REQUEST\r\n\r\n".as_bytes())?;
416    }
417    stream.set_nonblocking(false)?;
418    buf_writer.flush()?;
419    Ok(())
420}