Create a Static Site Generation platform; Deduplicate hot reloading code (#2226)

* create static site generation helpers in the router crate

* work on integrating static site generation into fullstack

* move ssg into a separate crate

* integrate ssg with the launch builder

* simplify ssg example

* fix static_routes for child routes

* move CLI hot reloading websocket code into dioxus-hot-reload

* fix some unused imports

* use the same hot reloading websocket code for fullstack

* fix fullstack hot reloading

* move cli hot reloading logic into the hot reload crate

* ssg example working with dx serve

* add more examples

* fix clippy

* fix formatting

* fix hot reload doctest imports

* fix axum imports

* don't run server doc tests

* Fix hot reload websocket doc examples
This commit is contained in:
Evan Almloff authored and GitHub committed 2024-05-22 07:24:15 -05:00
1 parent 460b70e0f0
commit 245003a5d4
59 files changed
+1507 -791

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
//! Launch helper macros for fullstack apps
use std::path::PathBuf;
/// Settings for a staticly generated site that may be hydrated in the browser
pub struct Config {
#[cfg(feature = "server")]
pub(crate) output_dir: PathBuf,
#[cfg(feature = "server")]
pub(crate) index_html: Option<String>,
#[cfg(feature = "server")]
pub(crate) index_path: Option<PathBuf>,
#[cfg(feature = "server")]
#[allow(clippy::type_complexity)]
pub(crate) map_path: Option<Box<dyn Fn(&str) -> PathBuf + Send + Sync + 'static>>,
#[cfg(feature = "server")]
pub(crate) root_id: Option<&'static str>,
#[cfg(feature = "server")]
pub(crate) additional_routes: Vec<String>,
#[cfg(feature = "server")]
pub(crate) github_pages: bool,
#[cfg(feature = "web")]
#[allow(unused)]
pub(crate) web_cfg: dioxus_web::Config,
}
#[allow(clippy::derivable_impls)]
impl Default for Config {
fn default() -> Self {
Self {
#[cfg(feature = "server")]
output_dir: PathBuf::from("./static"),
#[cfg(feature = "server")]
index_html: None,
#[cfg(feature = "server")]
index_path: None,
#[cfg(feature = "server")]
map_path: None,
#[cfg(feature = "server")]
root_id: None,
#[cfg(feature = "server")]
additional_routes: vec!["/".to_string()],
#[cfg(feature = "server")]
github_pages: false,
#[cfg(feature = "web")]
web_cfg: dioxus_web::Config::default(),
}
}
}
// Note: This config intentionally leaves server options in even if the server feature is not enabled to make it easier to use the config without moving different parts of the builder under configs.
impl Config {
/// Create a new config for a static site generation app.
pub fn new() -> Self {
Self::default()
}
/// Set a mapping from the route to the file path. This will override the default mapping configured with `static_dir`.
/// The function should return the path to the folder to store the index.html file in.
///
/// This method will only effect static site generation.
#[allow(unused)]
pub fn map_path<F: Fn(&str) -> PathBuf + Send + Sync + 'static>(mut self, map_path: F) -> Self {
#[cfg(feature = "server")]
{
self.map_path = Some(Box::new(map_path));
}
self
}
/// Set the output directory for the static site generation. (defaults to ./static)
/// This is the directory that will be used to store the generated static html files.
///
/// This method will only effect static site generation.
#[allow(unused)]
pub fn output_dir(mut self, output_dir: PathBuf) -> Self {
#[cfg(feature = "server")]
{
self.output_dir = output_dir;
}
self
}
/// Set the id of the root element in the index.html file to place the prerendered content into. (defaults to main)
#[allow(unused)]
pub fn root_id(mut self, root_id: &'static str) -> Self {
#[cfg(feature = "server")]
{
self.root_id = Some(root_id);
}
self
}
/// Set the contents of the index.html file to be served. (precedence over index_path)
///
/// This method will only effect static site generation.
#[allow(unused)]
pub fn index_html(mut self, index_html: String) -> Self {
#[cfg(feature = "server")]
{
self.index_html = Some(index_html);
}
self
}
/// Set the path of the index.html file to be served. (defaults to {assets_path}/index.html)
///
/// This method will only effect static site generation.
#[allow(unused)]
pub fn index_path(mut self, index_path: PathBuf) -> Self {
#[cfg(feature = "server")]
{
self.index_path = Some(index_path);
}
self
}
/// Sets a list of static routes that will be pre-rendered and served in addition to the static routes in the router.
#[allow(unused)]
pub fn additional_routes(mut self, mut routes: Vec<String>) -> Self {
#[cfg(feature = "server")]
{
self.additional_routes.append(&mut routes);
}
self
}
/// A preset for github pages. This will output your files in the `/docs` directory and set up a `404.html` file.
pub fn github_pages(self) -> Self {
#[allow(unused_mut)]
let mut myself = self
.additional_routes(vec!["/404".into()])
.output_dir(PathBuf::from("./docs"));
#[cfg(feature = "server")]
{
myself.github_pages = true;
}
myself
}
/// Set the web config.
///
/// This method will only effect the hydrated web renderer.
#[cfg(feature = "web")]
#[cfg_attr(docsrs, doc(cfg(feature = "web")))]
pub fn web_cfg(self, web_cfg: dioxus_web::Config) -> Self {
Self { web_cfg, ..self }
}
}
#[cfg(feature = "server")]
impl Config {
pub(crate) fn fullstack_template(
&self,
server_context: &dioxus_fullstack::prelude::DioxusServerContext,
) -> impl dioxus_ssr::incremental::WrapBody {
use dioxus_fullstack::prelude::{FullstackHTMLTemplate, ServeConfig};
let mut cfg_builder = ServeConfig::builder();
if let Some(index_html) = &self.index_html {
cfg_builder = cfg_builder.index_html(index_html.clone());
}
if let Some(index_path) = &self.index_path {
cfg_builder = cfg_builder.index_path(index_path.clone());
}
if let Some(root_id) = self.root_id {
cfg_builder = cfg_builder.root_id(root_id);
}
let cfg = cfg_builder.build();
FullstackHTMLTemplate::new(&cfg, server_context)
}
pub(crate) fn create_renderer(&mut self) -> dioxus_ssr::incremental::IncrementalRenderer {
let mut builder = dioxus_ssr::incremental::IncrementalRenderer::builder()
.static_dir(self.output_dir.clone())
.pre_render(true);
if let Some(map_path) = self.map_path.take() {
builder = builder.map_path(map_path);
}
builder.build()
}
}
+85
View File
@@ -0,0 +1,85 @@
//! This module contains the `launch` function, which is the main entry point for dioxus fullstack
use std::any::Any;
use dioxus_lib::prelude::{Element, VirtualDom};
pub use crate::Config;
/// Launch a fullstack app with the given root component, contexts, and config.
#[allow(unused)]
pub fn launch(
root: fn() -> Element,
contexts: Vec<Box<dyn Fn() -> Box<dyn Any> + Send + Sync>>,
platform_config: Config,
) {
let virtual_dom_factory = move || {
let mut vdom = VirtualDom::new(root);
for context in &contexts {
vdom.insert_any_root_context(context());
}
vdom
};
#[cfg(feature = "server")]
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async move {
use axum::extract::Path;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use axum::ServiceExt;
use dioxus_hot_reload::HotReloadRouterExt;
use http::StatusCode;
use tower_http::services::ServeDir;
use tower_http::services::ServeFile;
let github_pages = platform_config.github_pages;
let path = platform_config.output_dir.clone();
crate::ssg::generate_static_site(root, platform_config)
.await
.unwrap();
// Serve the program if we are running with cargo
if std::env::var_os("CARGO").is_some() || std::env::var_os("DIOXUS_ACTIVE").is_some() {
println!(
"Serving static files from {} at http://127.0.0.1:8080",
path.display()
);
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080));
let mut serve_dir =
ServeDir::new(path.clone()).call_fallback_on_method_not_allowed(true);
let mut router = axum::Router::new().forward_cli_hot_reloading();
// If we are acting like github pages, we need to serve the 404 page if the user requests a directory that doesn't exist
router = if github_pages {
router.fallback_service(
serve_dir.fallback(ServeFile::new(path.join("404/index.html"))),
)
} else {
router.fallback_service(serve_dir.fallback(get(|| async move {
"The requested path does not exist"
.to_string()
.into_response()
})))
};
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, router.into_make_service())
.await
.unwrap();
}
});
#[cfg(not(feature = "server"))]
{
#[cfg(feature = "web")]
{
let cfg = platform_config.web_cfg.hydrate(true);
dioxus_web::launch::launch_virtual_dom(virtual_dom_factory(), cfg);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
#![allow(non_snake_case)]
mod config;
pub use config::*;
pub mod launch;
#[cfg(feature = "server")]
pub(crate) mod ssg;
+164
View File
@@ -0,0 +1,164 @@
use dioxus_lib::prelude::*;
use dioxus_router::prelude::*;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use crate::Config;
fn server_context_for_route(route: &str) -> dioxus_fullstack::prelude::DioxusServerContext {
use dioxus_fullstack::prelude::*;
use std::sync::Arc;
let request = http::Request::builder().uri(route).body(()).unwrap();
let (parts, _) = request.into_parts();
DioxusServerContext::new(Arc::new(tokio::sync::RwLock::new(parts)))
}
/// Try to extract the site map by finding the root router that a component renders.
fn extract_site_map(app: fn() -> Element) -> Option<&'static [SiteMapSegment]> {
let mut vdom = VirtualDom::new(app);
vdom.rebuild_in_place();
vdom.in_runtime(|| {
ScopeId::ROOT.in_runtime(|| dioxus_router::prelude::root_router().map(|r| r.site_map()))
})
}
/// Generate a static site from any fullstack app that uses the router.
pub async fn generate_static_site(
app: fn() -> Element,
mut config: Config,
) -> Result<(), IncrementalRendererError> {
use tokio::task::block_in_place;
// Create the static output dir
std::fs::create_dir_all(&config.output_dir)?;
let mut renderer = config.create_renderer();
let mut routes_to_render: HashSet<String> = config.additional_routes.iter().cloned().collect();
if let Some(site_map) = block_in_place(|| extract_site_map(app)) {
let flat_site_map = site_map.iter().flat_map(SiteMapSegment::flatten);
for route in flat_site_map {
let Some(static_route) = route
.iter()
.map(SegmentType::to_static)
.collect::<Option<Vec<_>>>()
else {
continue;
};
let url = format!("/{}", static_route.join("/"));
routes_to_render.insert(url);
}
} else {
tracing::trace!("No site map found, rendering the additional routes");
}
for url in routes_to_render {
prerender_route(app, url, &mut renderer, &config).await?;
}
// Copy over the web output dir into the static output dir
let assets_path = dioxus_cli_config::CURRENT_CONFIG
.as_ref()
.map(|c| c.dioxus_config.application.out_dir.clone())
.unwrap_or("./dist".into());
copy_static_files(&assets_path, &config.output_dir)?;
Ok(())
}
fn copy_static_files(src: &Path, dst: &Path) -> Result<(), std::io::Error> {
let index_path = src.join("index.html");
let mut queue = vec![src.to_path_buf()];
while let Some(path) = queue.pop() {
if path == index_path {
continue;
}
if path.is_dir() {
for entry in fs::read_dir(&path).into_iter().flatten().flatten() {
let path = entry.path();
queue.push(path);
}
} else {
let output_location = dst.join(path.strip_prefix(src).unwrap());
let parent = output_location.parent().unwrap();
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
fs::copy(&path, output_location)?;
}
}
Ok(())
}
async fn prerender_route(
app: fn() -> Element,
route: String,
renderer: &mut dioxus_ssr::incremental::IncrementalRenderer,
config: &Config,
) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> {
use dioxus_fullstack::prelude::*;
let context = server_context_for_route(&route);
let wrapper = config.fullstack_template(&context);
renderer
.render(
route,
|| VirtualDom::new(app),
&mut tokio::io::sink(),
|vdom| {
Box::pin(async move {
with_server_context(context.clone(), || {
tokio::task::block_in_place(|| vdom.rebuild_in_place());
});
ProvideServerContext::new(vdom.wait_for_suspense(), context).await;
})
},
&wrapper,
)
.await?;
Ok(())
}
#[test]
fn extract_site_map_works() {
use dioxus::prelude::*;
#[derive(Clone, Routable, Debug, PartialEq)]
enum Route {
#[route("/")]
Home {},
#[route("/about")]
About {},
}
fn Home() -> Element {
rsx! { "Home" }
}
fn About() -> Element {
rsx! { "About" }
}
fn app() -> Element {
rsx! {
div {
Other {}
}
}
}
fn Other() -> Element {
rsx! {
Router::<Route> {}
}
}
let site_map = extract_site_map(app);
assert_eq!(site_map, Some(Route::SITE_MAP));
}