diff --git a/packages/perseus/Cargo.toml b/packages/perseus/Cargo.toml index f17445caf6..d1ab2db0ca 100644 --- a/packages/perseus/Cargo.toml +++ b/packages/perseus/Cargo.toml @@ -35,6 +35,7 @@ fs_extra = "1" http = "0.2" urlencoding = "2.1" chrono = "0.4" +minify-html-onepass = "0.10" [target.'cfg(target_arch = "wasm32")'.dependencies] rexie = { version = "0.2", optional = true } @@ -48,7 +49,7 @@ wasm-bindgen-futures = "0.4" [features] # Live reloading will only take effect in development, and won't impact production # BUG This adds 1.9kB to the production bundle (that's without size optimizations though) -default = [ "live-reload", "hsr", "client-helpers", "macros", "dflt-engine" ] +default = [ "live-reload", "hsr", "client-helpers", "macros", "dflt-engine", "minify" ] translator-fluent = ["fluent-bundle", "unic-langid", "intl-memoizer"] translator-lightweight = [] # This feature adds support for a number of macros that will make your life MUCH easier (read: use this unless you have very specific needs or are completely insane) @@ -57,6 +58,9 @@ macros = [ "perseus-macro" ] dflt-engine = [] # This features enables client-side helpers designed to be run in the browser. client-helpers = [ "console_error_panic_hook" ] +# This feature enables the minification of HTML, CSS, and JS assets, improving your page load speeds. You should only disable this if you're having issues with invalid +# HTML. +minify = [] # This feature enables Sycamore hydration by default (Sycamore hydration feature is always activated though) # This is not enabled by default due to some remaining bugs (also, default features in Perseus can't be disabled without altering `.perseus/`) hydrate = [] diff --git a/packages/perseus/src/build.rs b/packages/perseus/src/build.rs index 9392063159..5457099492 100644 --- a/packages/perseus/src/build.rs +++ b/packages/perseus/src/build.rs @@ -6,6 +6,7 @@ use crate::stores::{ImmutableStore, MutableStore}; use crate::template::Template; use crate::template::{PageProps, TemplateMap}; use crate::translator::Translator; +use crate::utils::minify; use futures::future::try_join_all; use std::collections::HashMap; use sycamore::prelude::SsrNode; @@ -147,6 +148,7 @@ async fn gen_state_for_path( let prerendered = sycamore::render_to_string(|cx| { template.render_for_template_server(page_props.clone(), cx, translator) }); + minify(&prerendered, true)?; // Write that prerendered HTML to a static file mutable_store .write(&format!("static/{}.html", full_path_encoded), &prerendered) @@ -155,6 +157,7 @@ async fn gen_state_for_path( // If the page also uses request state, amalgamation will be applied as for the // normal content let head_str = template.render_head_str(page_props, translator); + minify(&head_str, true)?; mutable_store .write( &format!("static/{}.head.html", full_path_encoded), @@ -184,6 +187,7 @@ async fn gen_state_for_path( let prerendered = sycamore::render_to_string(|cx| { template.render_for_template_server(page_props.clone(), cx, translator) }); + minify(&prerendered, true)?; // Write that prerendered HTML to a static file immutable_store .write(&format!("static/{}.html", full_path_encoded), &prerendered) diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index c5d5d152be..2a12054d95 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -93,6 +93,13 @@ pub enum ServerError { #[source] source: Box, }, + // We should only get a failure to minify if the user has given invalid HTML, or if Sycamore + // stuffed up somewhere + #[error("failed to minify html (you can disable the `minify` flag to avoid this; this is very likely a Sycamore bug, unless you've provided invalid custom HTML)")] + MinifyError { + #[source] + source: std::io::Error, + }, #[error(transparent)] GlobalStateError(#[from] GlobalStateError), #[error(transparent)] diff --git a/packages/perseus/src/server/html_shell.rs b/packages/perseus/src/server/html_shell.rs index 23ab0a193c..80bfc7d0b2 100644 --- a/packages/perseus/src/server/html_shell.rs +++ b/packages/perseus/src/server/html_shell.rs @@ -1,5 +1,8 @@ +use fmterr::fmterr; + use crate::error_pages::ErrorPageData; use crate::page_data::PageData; +use crate::utils::minify; use std::collections::HashMap; use std::{env, fmt}; @@ -116,9 +119,9 @@ impl HtmlShell { let port = env::var("PERSEUS_RELOAD_SERVER_PORT").unwrap_or_else(|_| "3100".to_string()); scripts_before_boundary - .push(format!("window.__PERSEUS_RELOAD_SERVER_HOST = '{}'", host)); + .push(format!("window.__PERSEUS_RELOAD_SERVER_HOST = '{}';", host)); scripts_before_boundary - .push(format!("window.__PERSEUS_RELOAD_SERVER_PORT = '{}'", port)); + .push(format!("window.__PERSEUS_RELOAD_SERVER_PORT = '{}';", port)); } // Add in the `` element at the very top so that it applies to everything @@ -337,6 +340,17 @@ impl fmt::Display for HtmlShell { .replace(&html_to_replace_double, &html_replacement) .replace(&html_to_replace_single, &html_replacement); - f.write_str(&new_shell) + // And minify everything + // Because this is run on live requests, we have to be fault-tolerant (if we + // can't minify, we'll fall back to unminified) + let minified = match minify(&new_shell, true) { + Ok(minified) => minified, + Err(err) => { + eprintln!("{}", fmterr(&err)); + new_shell + } + }; + + f.write_str(&minified) } } diff --git a/packages/perseus/src/utils/minify.rs b/packages/perseus/src/utils/minify.rs new file mode 100644 index 0000000000..050b89264f --- /dev/null +++ b/packages/perseus/src/utils/minify.rs @@ -0,0 +1,31 @@ +use crate::errors::*; +use minify_html_onepass::{with_friendly_error, Cfg}; + +/// Minifies the given HTML document, including any CSS and JS. This is +/// *extremely* fast, and can be reasonably run before returning a request. +/// +/// If the second argument is set to `false`, CSS and JS will not be minified, +/// and the performance will be improved. +pub(crate) fn minify(code: &str, minify_extras: bool) -> Result { + // In case the user is using invalid HTML (very tricky error to track down), we + // let them disable this feature + if cfg!(feature = "minify") { + let cfg = Cfg { + minify_js: false, // Pending wilsonzlin/minify-js#1 + minify_css: minify_extras, + }; + let mut bytes = code.as_bytes().to_vec(); + + match with_friendly_error(&mut bytes, &cfg) { + Ok(min_len) => Ok(std::str::from_utf8(&bytes[..min_len]).unwrap().to_string()), + Err(err) => Err(ServerError::MinifyError { + // We have to wrap this because the error types are non-`StdError` + // We want the error to be nice for the user (and it's not a security risk, since + // the HTML is being sent to the client anyway, if this is being run on the server) + source: std::io::Error::new(std::io::ErrorKind::NotFound, format!("{:#?}", err)), + }), + } + } else { + Ok(code.to_string()) + } +} diff --git a/packages/perseus/src/utils/mod.rs b/packages/perseus/src/utils/mod.rs index 54ebc780d0..f686165849 100644 --- a/packages/perseus/src/utils/mod.rs +++ b/packages/perseus/src/utils/mod.rs @@ -8,6 +8,8 @@ mod decode_time_str; #[cfg(target_arch = "wasm32")] mod fetch; mod log; +#[cfg(not(target_arch = "wasm32"))] +mod minify; mod path_prefix; #[cfg(target_arch = "wasm32")] mod replace_head; @@ -22,6 +24,8 @@ pub(crate) use context::provide_context_signal_replace; pub use decode_time_str::{ComputedDuration, InvalidDuration, PerseusDuration}; /* These have dummy equivalents for the browser */ #[cfg(target_arch = "wasm32")] pub(crate) use fetch::fetch; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use minify::minify; pub use path_prefix::*; #[cfg(target_arch = "wasm32")] pub(crate) use replace_head::replace_head;