#![doc = document_features::document_features!()]
#![forbid(unsafe_code)]
#![warn(clippy::all, rust_2018_idioms)]
use std::borrow::Cow;
use std::fmt::Display;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
pub const DEFAULT_WEB_VIEWER_SERVER_PORT: u16 = 9090;
#[cfg(all(not(disable_web_viewer_server), trailing_web_viewer))]
mod trailing_data;
#[derive(thiserror::Error, Debug)]
pub enum WebViewerServerError {
#[error("Could not parse address: {0}")]
AddrParseFailed(#[from] std::net::AddrParseError),
#[error("Failed to create server: {source}: ({address})")]
CreateServerFailed {
source: Box<dyn std::error::Error + Send + Sync + 'static>,
address: String,
},
#[error(transparent)]
FailedToLoadData(#[from] WebViewerDataError),
}
#[derive(thiserror::Error, Debug)]
pub enum WebViewerDataError {
#[error("Failed to get current executable path: {0}")]
CurrentExe(std::io::Error),
#[error("Failed to open executable: {source}, path: {path}")]
OpenFile {
path: std::path::PathBuf,
source: std::io::Error,
},
#[error("Failed to read executable metadata: {0}")]
ExeMetadata(std::io::Error),
#[error("Failed to open web viewer assets archive: {source}, path: {path}")]
OpenArchive {
path: std::path::PathBuf,
source: std::io::Error,
},
#[error(
"This build contains no built-in web viewer assets (RERUN_EXTERNAL_WEB_VIEWER=1). The assets must be loaded from a zip archive on disk, but no archive path was provided."
)]
NoBuiltinAssets,
#[error("Failed to read trailer from executable: {0}")]
ReadTrailer(std::io::Error),
#[error(
"Invalid magic marker in trailing data. Expected {expected:?}, got {actual:?}. This binary was built with RERUN_TRAILING_WEB_VIEWER=1 but the post-processing step (scripts/append_web_viewer.py) has not been completed."
)]
InvalidMagic {
expected: &'static [u8],
actual: Vec<u8>,
},
#[error("Failed to seek to zip offset {offset} in executable: {source}")]
SeekToZip { offset: u64, source: std::io::Error },
#[error("Failed to read {size} bytes of zip data: {source}")]
ReadZip { size: u64, source: std::io::Error },
#[error("Failed to parse zip archive: {0}. The data may be corrupted.")]
ParseZip(zip::result::ZipError),
#[error("Failed to extract file '{name}' from zip archive: {source}")]
ExtractFile {
name: String,
source: zip::result::ZipError,
},
#[error("Failed to read file '{name}' contents: {source}")]
ReadFileContents {
name: String,
source: std::io::Error,
},
}
pub struct WebViewerData {
index_html: Cow<'static, [u8]>,
favicon: Cow<'static, [u8]>,
apple_touch_icon: Cow<'static, [u8]>,
sw_js: Cow<'static, [u8]>,
viewer_js: Cow<'static, [u8]>,
viewer_wasm: Cow<'static, [u8]>,
signed_in_html: Cow<'static, [u8]>,
signed_out_html: Cow<'static, [u8]>,
}
impl std::fmt::Debug for WebViewerData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
index_html,
favicon,
apple_touch_icon,
sw_js,
viewer_js,
viewer_wasm,
signed_in_html,
signed_out_html,
} = self;
let mut f = f.debug_struct("WebViewerData");
for (name, contents) in [
("index_html", index_html),
("favicon", favicon),
("apple_touch_icon", apple_touch_icon),
("sw_js", sw_js),
("viewer_js", viewer_js),
("viewer_wasm", viewer_wasm),
("signed_in_html", signed_in_html),
("signed_out_html", signed_out_html),
] {
f.field(name, &format_args!("<{} bytes>", contents.len()));
}
f.finish()
}
}
#[cfg(not(disable_web_viewer_server))]
impl WebViewerData {
pub fn load(assets_archive_path: Option<&Path>) -> Result<Self, WebViewerDataError> {
match assets_archive_path {
Some(path) => Self::from_archive(path),
None => Self::builtin(),
}
}
pub fn from_archive(path: &Path) -> Result<Self, WebViewerDataError> {
let file = std::fs::File::open(path).map_err(|source| WebViewerDataError::OpenArchive {
path: path.to_owned(),
source,
})?;
let mut zip = zip::ZipArchive::new(std::io::BufReader::new(file))
.map_err(WebViewerDataError::ParseZip)?;
Self::from_zip(&mut zip)
}
fn from_zip<R: std::io::Read + std::io::Seek>(
zip: &mut zip::ZipArchive<R>,
) -> Result<Self, WebViewerDataError> {
fn extract_file<R: std::io::Read + std::io::Seek>(
zip: &mut zip::ZipArchive<R>,
name: &str,
) -> Result<Cow<'static, [u8]>, WebViewerDataError> {
use std::io::Read as _;
let mut file = zip
.by_name(name)
.map_err(|source| WebViewerDataError::ExtractFile {
name: name.to_owned(),
source,
})?;
let mut contents = Vec::with_capacity(file.size() as usize);
file.read_to_end(&mut contents).map_err(|source| {
WebViewerDataError::ReadFileContents {
name: name.to_owned(),
source,
}
})?;
Ok(Cow::Owned(contents))
}
Ok(Self {
index_html: extract_file(zip, "index.html")?,
favicon: extract_file(zip, "favicon.ico")?,
apple_touch_icon: extract_file(zip, "apple-touch-icon.png")?,
sw_js: extract_file(zip, "sw.js")?,
viewer_js: extract_file(zip, "re_viewer.js")?,
viewer_wasm: extract_file(zip, "re_viewer_bg.wasm")?,
signed_in_html: extract_file(zip, "signed-in.html")?,
signed_out_html: extract_file(zip, "signed-out.html")?,
})
}
#[cfg(all(not(trailing_web_viewer), not(external_web_viewer)))]
#[expect(clippy::large_include_file)]
#[expect(clippy::unnecessary_wraps)] fn builtin() -> Result<Self, WebViewerDataError> {
Ok(Self {
index_html: Cow::Borrowed(include_bytes!("../web_viewer/index.html")),
favicon: Cow::Borrowed(include_bytes!("../web_viewer/favicon.ico")),
apple_touch_icon: Cow::Borrowed(include_bytes!("../web_viewer/apple-touch-icon.png")),
sw_js: Cow::Borrowed(include_bytes!("../web_viewer/sw.js")),
viewer_js: Cow::Borrowed(include_bytes!("../web_viewer/re_viewer.js")),
viewer_wasm: Cow::Borrowed(include_bytes!("../web_viewer/re_viewer_bg.wasm")),
signed_in_html: Cow::Borrowed(include_bytes!("../web_viewer/signed-in.html")),
signed_out_html: Cow::Borrowed(include_bytes!("../web_viewer/signed-out.html")),
})
}
#[cfg(trailing_web_viewer)]
fn builtin() -> Result<Self, WebViewerDataError> {
let zip_bytes = trailing_data::read_zip_from_exe()?;
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
.map_err(WebViewerDataError::ParseZip)?;
Self::from_zip(&mut zip)
}
#[cfg(external_web_viewer)]
fn builtin() -> Result<Self, WebViewerDataError> {
Err(WebViewerDataError::NoBuiltinAssets)
}
}
impl WebViewerData {
#[inline]
pub fn index_html(&self) -> &[u8] {
&self.index_html
}
#[inline]
pub fn favicon(&self) -> &[u8] {
&self.favicon
}
#[inline]
pub fn apple_touch_icon(&self) -> &[u8] {
&self.apple_touch_icon
}
#[inline]
pub fn sw_js(&self) -> &[u8] {
&self.sw_js
}
#[inline]
pub fn viewer_js(&self) -> &[u8] {
&self.viewer_js
}
#[inline]
pub fn viewer_wasm(&self) -> &[u8] {
&self.viewer_wasm
}
#[inline]
pub fn signed_in_html(&self) -> &[u8] {
&self.signed_in_html
}
#[inline]
pub fn signed_out_html(&self) -> &[u8] {
&self.signed_out_html
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WebViewerServerPort(pub u16);
impl From<u16> for WebViewerServerPort {
#[inline]
fn from(port: u16) -> Self {
Self(port)
}
}
impl WebViewerServerPort {
pub const AUTO: Self = Self(0);
}
impl Default for WebViewerServerPort {
fn default() -> Self {
Self(DEFAULT_WEB_VIEWER_SERVER_PORT)
}
}
impl Display for WebViewerServerPort {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for WebViewerServerPort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.parse::<u16>() {
Ok(port) => Ok(Self(port)),
Err(err) => Err(format!("Failed to parse port: {err}")),
}
}
}
#[must_use = "Dropping this means stopping the server"]
pub struct WebViewerServer {
inner: Arc<WebViewerServerInner>,
thread_handle: Option<std::thread::JoinHandle<()>>,
}
struct WebViewerServerInner {
server: tiny_http::Server,
shutdown: AtomicBool,
num_wasm_served: AtomicU64,
#[cfg(not(disable_web_viewer_server))]
data: WebViewerData,
}
impl WebViewerServer {
pub fn new(bind_ip: &str, port: WebViewerServerPort) -> Result<Self, WebViewerServerError> {
Self::with_archive(bind_ip, port, None)
}
pub fn with_archive(
bind_ip: &str,
port: WebViewerServerPort,
assets_archive_path: Option<&Path>,
) -> Result<Self, WebViewerServerError> {
cfg_select! {
disable_web_viewer_server => {
let _ = assets_archive_path;
}
_ => {
let data = WebViewerData::load(assets_archive_path)?;
}
}
let bind_addr = std::net::SocketAddr::new(bind_ip.parse()?, port.0);
let server = tiny_http::Server::http(bind_addr).map_err(|err| {
WebViewerServerError::CreateServerFailed {
address: bind_addr.to_string(),
source: err,
}
})?;
let shutdown = AtomicBool::new(false);
let inner = Arc::new(WebViewerServerInner {
server,
shutdown,
num_wasm_served: Default::default(),
#[cfg(not(disable_web_viewer_server))]
data,
});
let inner_copy = inner.clone();
let thread_handle = std::thread::Builder::new()
.name("re_web_viewer_server".to_owned())
.spawn(move || inner_copy.serve())
.ok();
Ok(Self {
inner,
thread_handle,
})
}
pub fn server_url(&self) -> String {
let local_addr = self.inner.server.server_addr();
if let Some(local_addr) = local_addr.clone().to_ip()
&& local_addr.ip().is_unspecified()
{
return format!("http://127.0.0.1:{}", local_addr.port());
}
format!("http://{local_addr}")
}
pub fn bound_url(&self) -> String {
format!("http://{}", self.inner.server.server_addr())
}
pub fn block(mut self) {
if let Some(thread_handle) = self.thread_handle.take() {
thread_handle.join().ok();
}
}
pub fn detach(mut self) {
if let Some(thread_handle) = self.thread_handle.take() {
drop(thread_handle);
}
}
}
impl Drop for WebViewerServer {
fn drop(&mut self) {
if let Some(thread_handle) = self.thread_handle.take() {
let num_wasm_served = self.inner.num_wasm_served.load(Ordering::Relaxed);
re_log::debug!(
"Shutting down web server after serving the Wasm {num_wasm_served} time(s)"
);
self.inner.shutdown.store(true, Ordering::Release);
self.inner.server.unblock();
thread_handle.join().ok();
}
}
}
impl WebViewerServerInner {
fn serve(&self) {
loop {
let request = self.server.recv();
if self.shutdown.load(Ordering::Acquire) {
return;
}
let request = match request {
Ok(request) => request,
Err(err) => {
re_log::error!("Failed to receive http request: {err}");
continue;
}
};
if let Err(err) = self.send_response(request) {
re_log::error!("Failed to send http response: {err}");
}
}
}
fn on_serve_wasm(&self) {
self.num_wasm_served.fetch_add(1, Ordering::Relaxed);
#[cfg(feature = "analytics")]
re_analytics::record(|| re_analytics::event::ServeWasm);
}
#[cfg(disable_web_viewer_server)]
fn send_response(&self, _request: tiny_http::Request) -> Result<(), std::io::Error> {
if false {
self.on_serve_wasm(); }
panic!(
"re_web_viewer_server compiled without .wasm, because of '__disable_server' feature, `--all-features`, or 'RERUN_DISABLE_WEB_VIEWER_SERVER=1'. DON'T DO THAT! It's only meant for tests and docs!"
);
}
#[cfg(not(disable_web_viewer_server))]
fn send_response(&self, request: tiny_http::Request) -> Result<(), std::io::Error> {
let url = request.url();
let path = url.split('?').next().unwrap_or(url);
let data = &self.data;
let (mime, bytes): (&str, &[u8]) = match path {
"/" | "/index.html" => ("text/html", data.index_html()),
"/favicon.ico" => ("image/x-icon", data.favicon()),
"/apple-touch-icon.png" => ("image/png", data.apple_touch_icon()),
"/sw.js" => ("text/javascript", data.sw_js()),
"/re_viewer.js" => ("text/javascript", data.viewer_js()),
"/re_viewer_bg.wasm" => {
self.on_serve_wasm();
("application/wasm", data.viewer_wasm())
}
"/signed-in" => ("text/html", data.signed_in_html()),
"/signed-out" => ("text/html", data.signed_out_html()),
_ => {
re_log::warn!("404 path: {}", path);
return request.respond(tiny_http::Response::empty(404));
}
};
let mut response = tiny_http::Response::from_data(bytes).with_header(
tiny_http::Header::from_str(&format!("Content-Type: {mime}"))
.expect("Invalid http header"),
);
if let Ok(header) =
tiny_http::Header::from_str(&format!("rerun-final-length: {}", bytes.len()))
{
response.add_header(header);
}
request.respond(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unspecified_bind_address_has_distinct_bound_and_connect_urls() {
let server = WebViewerServer::new("0.0.0.0", WebViewerServerPort::AUTO).unwrap();
let port = server.inner.server.server_addr().to_ip().unwrap().port();
assert_eq!(server.bound_url(), format!("http://0.0.0.0:{port}"));
assert_eq!(server.server_url(), format!("http://127.0.0.1:{port}"));
}
#[cfg(not(disable_web_viewer_server))]
const ASSET_FILE_NAMES: [&str; 8] = [
"index.html",
"favicon.ico",
"apple-touch-icon.png",
"sw.js",
"re_viewer.js",
"re_viewer_bg.wasm",
"signed-in.html",
"signed-out.html",
];
#[cfg(not(disable_web_viewer_server))]
fn write_asset_archive(path: &Path, file_names: &[&str]) {
use std::io::Write as _;
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for name in file_names {
zip.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(name.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
#[test]
#[cfg(not(disable_web_viewer_server))]
fn load_data_from_archive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("web_viewer.zip");
write_asset_archive(&path, &ASSET_FILE_NAMES);
let data = WebViewerData::from_archive(&path).unwrap();
assert_eq!(data.index_html(), b"index.html");
assert_eq!(data.viewer_wasm(), b"re_viewer_bg.wasm");
assert_eq!(data.signed_out_html(), b"signed-out.html");
}
#[test]
#[cfg(not(disable_web_viewer_server))]
fn archive_with_missing_file_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("web_viewer.zip");
write_asset_archive(&path, &ASSET_FILE_NAMES[..7]);
let err = WebViewerData::from_archive(&path).unwrap_err();
assert!(
matches!(&err, WebViewerDataError::ExtractFile { name, .. } if name == "signed-out.html"),
"unexpected error: {err}"
);
}
#[test]
#[cfg(not(disable_web_viewer_server))]
fn missing_archive_fails() {
let err =
WebViewerData::from_archive(Path::new("/nonexistent/web_viewer.zip")).unwrap_err();
assert!(
matches!(err, WebViewerDataError::OpenArchive { .. }),
"unexpected error: {err}"
);
}
}