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}