diff --git a/Cargo.toml b/Cargo.toml index 010abd9f43..4e8f92f5dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,21 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +sycamore = { version = "0.5.1", features = ["ssr"] } +sycamore-router = "0.5.1" +web-sys = { version = "0.3", features = ["Headers", "Request", "RequestInit", "RequestMode", "Response", "ReadableStream", "Window"] } +wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } +wasm-bindgen-futures = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +typetag = "0.1" +error-chain = "0.12" +futures = "0.3" +console_error_panic_hook = "0.1.6" +urlencoding = "2.1" + +[workspace] +members = [ + "examples/showcase/app", + "examples/showcase/server" +] \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index d74e4a39f2..3f83de61e7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,4 +4,18 @@ This folder contains examples for Perseus, which are used to test the project an These examples are all fully self-contained, and do not serve as examples in the traditional Cargo way, they are each indepedent crates to enable the use of build tools such as `wasm-pack`. +## Workspaces?? + +A Perseus setup is composed of an app and a server, which would normally be in a workspace project. However, examples with their own `Cargo.toml` manifests aren't detected by RLS, and so we need to make them part of the super-workspace at the root. The problem with that is that then we'd have nested workspaces, which are currently impossible. The solution used is to have each example be atomic (i.e. app OR server), but they're still listed together under the same parent directory. If you want to clone one of these to run locally without the rest of the repo, you'll need to get the appropriate directory with both an app and a server, and then add a new `Cargo.toml` at the root of that with the following contents: + +```toml +[workspace] +members = [ + "app", + "server" +] +``` + +All examples should have both an `app` and a `server` directory. + - Showcase -- an app that demonstrates all the different features of Perseus, including SSR, SSG, and ISR (this example is actively used for testing) diff --git a/examples/showcase/Cargo.toml b/examples/showcase/Cargo.toml deleted file mode 100644 index 9159e6c9db..0000000000 --- a/examples/showcase/Cargo.toml +++ /dev/null @@ -1,5 +0,0 @@ -[workspace] -members = [ - "app", - "server" -] \ No newline at end of file diff --git a/examples/showcase/app/Cargo.toml b/examples/showcase/app/Cargo.toml index 5cf539bc4b..4458ded5e6 100644 --- a/examples/showcase/app/Cargo.toml +++ b/examples/showcase/app/Cargo.toml @@ -6,16 +6,13 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +perseus = { path = "../../../" } sycamore = { version = "0.5.1", features = ["ssr"] } sycamore-router = "0.5.1" web-sys = { version = "0.3", features = ["Headers", "Request", "RequestInit", "RequestMode", "Response", "ReadableStream", "Window"] } wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } -wasm-bindgen-futures = "0.4" serde = { version = "1", features = ["derive"] } -serde_json = "1" -typetag = "0.1" -error-chain = "0.12" -futures = "0.3" +serde_json = "1" # Possibly don't need? console_error_panic_hook = "0.1.6" urlencoding = "2.1" diff --git a/examples/showcase/app/src/bin/build.rs b/examples/showcase/app/src/bin/build.rs index 25cee72454..90ef8f3603 100644 --- a/examples/showcase/app/src/bin/build.rs +++ b/examples/showcase/app/src/bin/build.rs @@ -1,16 +1,17 @@ -use perseus_showcase_app::{ - pages, +use perseus::{ config_manager::{FsConfigManager, ConfigManager}, build_pages }; +use perseus_showcase_app::pages; +use sycamore::prelude::SsrNode; fn main() { let config_manager = FsConfigManager::new(); build_pages!([ - pages::index::get_page(), - pages::about::get_page(), - pages::post::get_page() + pages::index::get_page::(), + pages::about::get_page::(), + pages::post::get_page::() ], &config_manager); println!("Static generation successfully completed!"); diff --git a/examples/showcase/app/src/lib.rs b/examples/showcase/app/src/lib.rs index 856658253d..23a9705e6c 100644 --- a/examples/showcase/app/src/lib.rs +++ b/examples/showcase/app/src/lib.rs @@ -1,15 +1,9 @@ -pub mod errors; pub mod pages; -mod shell; -pub mod serve; -pub mod render_cfg; -pub mod config_manager; -pub mod page; -pub mod build; use sycamore::prelude::*; use sycamore_router::{Route, BrowserRouter}; use wasm_bindgen::prelude::*; +use perseus::shell::app_shell; // Define our routes #[derive(Route)] @@ -44,26 +38,24 @@ pub fn run() -> Result<(), JsValue> { template! { BrowserRouter(|route: AppRoute| { match route { - AppRoute::Index => app_shell!({ - name => "index", - props => pages::index::IndexPageProps, - template => |props: Option| template! { + AppRoute::Index => app_shell( + "index".to_string(), + Box::new(|props: Option| template! { pages::index::IndexPage(props.unwrap()) - }, - }), - AppRoute::About => app_shell!({ - name => "about", - template => |_: Option<()>| template! { + }) + ), + AppRoute::About => app_shell( + "about".to_string(), + Box::new(|_: Option<()>| template! { pages::about::AboutPage() - }, - }), - AppRoute::Post { slug } => app_shell!({ - name => &format!("post/{}", slug), - props => pages::post::PostPageProps, - template => |props: Option| template! { + }) + ), + AppRoute::Post { slug } => app_shell( + format!("post/{}", slug), + Box::new(|props: Option| template! { pages::post::PostPage(props.unwrap()) - }, - }), + }) + ), AppRoute::NotFound => template! { p {"Not Found."} } diff --git a/examples/showcase/app/src/pages/about.rs b/examples/showcase/app/src/pages/about.rs index 962103be78..c3100a83d6 100644 --- a/examples/showcase/app/src/pages/about.rs +++ b/examples/showcase/app/src/pages/about.rs @@ -1,5 +1,5 @@ use sycamore::prelude::*; -use crate::page::Page; +use perseus::page::Page; #[component(AboutPage)] pub fn about_page() -> Template { @@ -8,7 +8,7 @@ pub fn about_page() -> Template { } } -pub fn get_page() -> Page<()> { +pub fn get_page() -> Page<(), G> { Page::new("about") .template(Box::new(|_| template! { AboutPage() diff --git a/examples/showcase/app/src/pages/index.rs b/examples/showcase/app/src/pages/index.rs index f9d1c9c4ff..80c242a9d4 100644 --- a/examples/showcase/app/src/pages/index.rs +++ b/examples/showcase/app/src/pages/index.rs @@ -1,6 +1,6 @@ use sycamore::prelude::*; use serde::{Serialize, Deserialize}; -use crate::page::Page; +use perseus::page::Page; #[derive(Serialize, Deserialize, Debug)] pub struct IndexPageProps { @@ -15,7 +15,7 @@ pub fn index_page(props: IndexPageProps) -> Template { } } -pub fn get_page() -> Page { +pub fn get_page() -> Page { Page::new("index") .build_state_fn(Box::new(get_static_props)) .template(Box::new(|props: Option| template! { diff --git a/examples/showcase/app/src/pages/post.rs b/examples/showcase/app/src/pages/post.rs index 2d76381a30..5b22710b06 100644 --- a/examples/showcase/app/src/pages/post.rs +++ b/examples/showcase/app/src/pages/post.rs @@ -1,6 +1,6 @@ use sycamore::prelude::*; use serde::{Serialize, Deserialize}; -use crate::page::Page; +use perseus::page::Page; #[derive(Serialize, Deserialize)] pub struct PostPageProps { @@ -22,7 +22,7 @@ pub fn post_page(props: PostPageProps) -> Template { } } -pub fn get_page() -> Page { +pub fn get_page() -> Page { Page::new("post") .build_paths_fn(Box::new(get_static_paths)) .build_state_fn(Box::new(get_static_props)) diff --git a/examples/showcase/app/src/shell.rs b/examples/showcase/app/src/shell.rs deleted file mode 100644 index 43f5f5fda5..0000000000 --- a/examples/showcase/app/src/shell.rs +++ /dev/null @@ -1,99 +0,0 @@ -use wasm_bindgen::prelude::*; -use wasm_bindgen::JsCast; -use wasm_bindgen_futures::{JsFuture}; -use web_sys::{Request, RequestInit, RequestMode, Response}; -use crate::errors::*; - -pub async fn fetch(url: String) -> Result> { - let js_err_handler = |err: JsValue| ErrorKind::JsErr(format!("{:?}", err)); - let mut opts = RequestInit::new(); - opts - .method("GET") - .mode(RequestMode::Cors); - - let request = Request::new_with_str_and_init(&url, &opts).map_err(js_err_handler)?; - - let window = web_sys::window().unwrap(); - // Get the response as a future and await it - let res_value = JsFuture::from(window.fetch_with_request(&request)).await.map_err(js_err_handler)?; - // Turn that into a proper response object - let res: Response = res_value.dyn_into().unwrap(); - // If the status is 404, we should return that the request worked but no file existed - if res.status() == 404 { - return Ok(None); - } - // Get the body thereof - let body_promise = res.text().map_err(js_err_handler)?; - let body = JsFuture::from(body_promise).await.map_err(js_err_handler)?; - - // Convert that into a string (this will be `None` if it wasn't a string in the JS) - // TODO return error if string serialization fails - Ok(body.as_string()) -} - -// This fetches the static resources for a page and renders it -#[macro_export] -macro_rules! app_shell { - ({ - name => $name:expr, - $(props => $props:ty,)? - template => $template:expr, - }) => { - { - // Get the container as a DOM element - let container = NodeRef::new(); - // Spawn a Rust futures thread in the background to fetch the static HTML/JSON - ::wasm_bindgen_futures::spawn_local(cloned!((container) => async move { - // Get the static page data - // If this doesn't exist, then it's a 404 (but we went here by explicit navigation, so there's been an error, should go to a special 404 page) - let page_data_str = $crate::shell::fetch(format!("/.perseus/page/{}", $name)).await; - let page_data_str = match page_data_str { - Ok(page_data) => match page_data { - Some(page_data) => page_data, - None => todo!("404 not yet implemented") - }, - Err(err) => todo!("error unimplemented") - }; - let page_data = serde_json::from_str::<$crate::serve::PageData>(&page_data_str); - let page_data = match page_data { - Ok(page_data) => page_data, - Err(err) => todo!("page data serialization error unimplemented") - }; - - // Interpolate the HTML directly into the document (we'll hydrate it later) - let container_elem = container.get::().unchecked_into::(); - container_elem.set_inner_html(&page_data.content); - - - let state = match page_data.state { - Some(state_str) => { - let mut state = Option::None; - $( - // Serialize that state into a valid properties struct - let state_res = ::serde_json::from_str::<$props>(&state_str); - state = match state_res { - Ok(state) => Some(state), - Err(err) => todo!("serialization error unimplemented") - }; - )? - state - }, - None => None - }; - - // Hydrate that static code using the acquired state - // BUG (Sycamore): this will double-render if the component is just text (no nodes) - sycamore::hydrate_to( - || $template(state), - &container.get::().inner_element() - ); - })); - - // This is where the static content will be rendered - // BUG: white flash of death until Sycamore can suspend the router until the static content is ready - template! { - div(ref = container) - } - } - }; -} diff --git a/examples/showcase/server/Cargo.toml b/examples/showcase/server/Cargo.toml index 9c9383c116..0a1e87b8bb 100644 --- a/examples/showcase/server/Cargo.toml +++ b/examples/showcase/server/Cargo.toml @@ -6,6 +6,7 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +perseus = { path = "../../../" } actix-web = "3.3" actix-files = "0.5" urlencoding = "2.1" diff --git a/examples/showcase/server/src/main.rs b/examples/showcase/server/src/main.rs index e0ee07f93b..5d870a09b6 100644 --- a/examples/showcase/server/src/main.rs +++ b/examples/showcase/server/src/main.rs @@ -1,8 +1,11 @@ use actix_web::{web, App, HttpRequest, HttpServer, Result as ActixResult, error}; use actix_files::{NamedFile}; -use perseus_showcase_app::serve::{get_render_cfg, get_page}; -use perseus_showcase_app::render_cfg::RenderCfg; -use perseus_showcase_app::config_manager::FsConfigManager; + +use perseus::{ + serve::{get_render_cfg, get_page}, + render_cfg::RenderCfg, + config_manager::FsConfigManager +}; #[actix_web::main] async fn main() -> std::io::Result<()> { diff --git a/examples/showcase/app/src/build.rs b/src/build.rs similarity index 97% rename from examples/showcase/app/src/build.rs rename to src/build.rs index cf37bceaad..2f0c5620d0 100644 --- a/examples/showcase/app/src/build.rs +++ b/src/build.rs @@ -8,9 +8,10 @@ use crate::{ }; use crate::errors::*; use std::any::Any; +use sycamore::prelude::SsrNode; /// Builds a page, writing static data as appropriate. This should be used as part of a larger build process. -pub fn build_page(page: Page, config_manager: &impl ConfigManager) -> Result> { +pub fn build_page(page: Page, config_manager: &impl ConfigManager) -> Result> { let mut render_opts: Vec = Vec::new(); let page_path = page.get_path(); diff --git a/examples/showcase/app/src/config_manager.rs b/src/config_manager.rs similarity index 100% rename from examples/showcase/app/src/config_manager.rs rename to src/config_manager.rs diff --git a/examples/showcase/app/src/errors.rs b/src/errors.rs similarity index 100% rename from examples/showcase/app/src/errors.rs rename to src/errors.rs diff --git a/src/lib.rs b/src/lib.rs index 31e1bb209f..dd29b112a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ -#[cfg(test)] -mod tests { - #[test] - fn it_works() { - assert_eq!(2 + 2, 4); - } -} +pub mod errors; +pub mod shell; +pub mod serve; +pub mod render_cfg; +pub mod config_manager; +pub mod page; +pub mod build; \ No newline at end of file diff --git a/examples/showcase/app/src/page.rs b/src/page.rs similarity index 90% rename from examples/showcase/app/src/page.rs rename to src/page.rs index 9fb0b5eb04..369c5aed56 100644 --- a/examples/showcase/app/src/page.rs +++ b/src/page.rs @@ -2,19 +2,19 @@ use crate::errors::*; use serde::{Serialize, de::DeserializeOwned}; +use sycamore::prelude::{Template, GenericNode}; // A series of closure types that should not be typed out more than once -// TODO maybe make these public? -type TemplateFnReturn = sycamore::prelude::Template; -type TemplateFn = Box) -> TemplateFnReturn>; -type GetBuildPathsFn = Box Vec>; -type GetBuildStateFn = Box Props>; -type GetRequestStateFn = Box Props>; -type ShouldRevalidateFn = Box bool>; +pub type TemplateFn = Box) -> Template>; +pub type GetBuildPathsFn = Box Vec>; +pub type GetBuildStateFn = Box Props>; +pub type GetRequestStateFn = Box Props>; +pub type ShouldRevalidateFn = Box bool>; /// This allows the specification of all the page templates in an app and how to render them. If no rendering logic is provided at all, /// the page will be prerendered at build-time with no state. All closures are stored on the heap to avoid hellish lifetime specification. -pub struct Page +// #[derive(Clone)] +pub struct Page { /// The path to the root of the template. Any build paths will be inserted under this. path: String, @@ -22,7 +22,7 @@ pub struct Page /// to be prerendered in some way. This should be very similar to the function that hydrates your page on the client side. /// This will be executed inside `sycamore::render_to_string`, and should return a `Template`. This takes an `Option` /// because otherwise efficient typing is almost impossible for pages without any properties (solutions welcome in PRs!). - template: TemplateFn, + template: TemplateFn, /// A function that gets the paths to render for at built-time. This is equivalent to `get_static_paths` in NextJS. If /// `incremental_path_rendering` is `true`, more paths can be rendered at request time on top of these. get_build_paths: Option, @@ -44,7 +44,7 @@ pub struct Page /// A length of time after which to prerender the page again. This is equivalent to ISR in NextJS. revalidate_after: Option, } -impl Page { +impl Page { /// Creates a new page definition. pub fn new(path: impl Into + std::fmt::Display) -> Self { Self { @@ -62,7 +62,7 @@ impl Page { // Render executors /// Executes the user-given function that renders the page on the server-side (build or request time). - pub fn render_for_template(&self, props: Option) -> TemplateFnReturn { + pub fn render_for_template(&self, props: Option) -> Template { (self.template)(props) } /// Gets the list of pages that should be prerendered for at build-time. @@ -122,31 +122,31 @@ impl Page { } // Builder setters - pub fn template(mut self, val: TemplateFn) -> Page { + pub fn template(mut self, val: TemplateFn) -> Page { self.template = val; self } - pub fn build_paths_fn(mut self, val: GetBuildPathsFn) -> Page { + pub fn build_paths_fn(mut self, val: GetBuildPathsFn) -> Page { self.get_build_paths = Some(val); self } - pub fn incremental_path_rendering(mut self, val: bool) -> Page { + pub fn incremental_path_rendering(mut self, val: bool) -> Page { self.incremental_path_rendering = val; self } - pub fn build_state_fn(mut self, val: GetBuildStateFn) -> Page { + pub fn build_state_fn(mut self, val: GetBuildStateFn) -> Page { self.get_build_state = Some(val); self } - pub fn request_state_fn(mut self, val: GetRequestStateFn) -> Page { + pub fn request_state_fn(mut self, val: GetRequestStateFn) -> Page { self.get_request_state = Some(val); self } - pub fn should_revalidate(mut self, val: ShouldRevalidateFn) -> Page { + pub fn should_revalidate(mut self, val: ShouldRevalidateFn) -> Page { self.should_revalidate = Some(val); self } - pub fn revalidate_after(mut self, val: String) -> Page { + pub fn revalidate_after(mut self, val: String) -> Page { self.revalidate_after = Some(val); self } diff --git a/examples/showcase/app/src/render_cfg.rs b/src/render_cfg.rs similarity index 100% rename from examples/showcase/app/src/render_cfg.rs rename to src/render_cfg.rs diff --git a/examples/showcase/app/src/serve.rs b/src/serve.rs similarity index 100% rename from examples/showcase/app/src/serve.rs rename to src/serve.rs diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000000..709fdebcab --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,91 @@ +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::{JsFuture}; +use web_sys::{Request, RequestInit, RequestMode, Response}; +use sycamore::prelude::*; +use serde::{Serialize, de::DeserializeOwned}; +use crate::errors::*; +use crate::serve::PageData; +use crate::page::TemplateFn; + +pub async fn fetch(url: String) -> Result> { + let js_err_handler = |err: JsValue| ErrorKind::JsErr(format!("{:?}", err)); + let mut opts = RequestInit::new(); + opts + .method("GET") + .mode(RequestMode::Cors); + + let request = Request::new_with_str_and_init(&url, &opts).map_err(js_err_handler)?; + + let window = web_sys::window().unwrap(); + // Get the response as a future and await it + let res_value = JsFuture::from(window.fetch_with_request(&request)).await.map_err(js_err_handler)?; + // Turn that into a proper response object + let res: Response = res_value.dyn_into().unwrap(); + // If the status is 404, we should return that the request worked but no file existed + if res.status() == 404 { + return Ok(None); + } + // Get the body thereof + let body_promise = res.text().map_err(js_err_handler)?; + let body = JsFuture::from(body_promise).await.map_err(js_err_handler)?; + + // Convert that into a string (this will be `None` if it wasn't a string in the JS) + // TODO return error if string serialization fails + Ok(body.as_string()) +} + +/// Fetches the information for the given page and renders it. This should be provided the actual path of the page to render (not just the +/// broader template). +// TODO fix static lifetime here +// TODO set up errors here +pub fn app_shell(path: String, template_fn: TemplateFn) -> Template { + // Get the container as a DOM element + let container = NodeRef::new(); + // Spawn a Rust futures thread in the background to fetch the static HTML/JSON + wasm_bindgen_futures::spawn_local(cloned!((container) => async move { + // Get the static page data + // If this doesn't exist, then it's a 404 (but we went here by explicit navigation, so there's been an error, should go to a special 404 page) + let page_data_str = fetch(format!("/.perseus/page/{}", path.to_string())).await; + let page_data_str = match page_data_str { + Ok(page_data) => match page_data { + Some(page_data) => page_data, + None => todo!("404 not yet implemented") + }, + Err(err) => todo!("error unimplemented") + }; + let page_data = serde_json::from_str::(&page_data_str); + let page_data = match page_data { + Ok(page_data) => page_data, + Err(err) => todo!("page data serialization error unimplemented") + }; + + // Interpolate the HTML directly into the document (we'll hydrate it later) + let container_elem = container.get::().unchecked_into::(); + container_elem.set_inner_html(&page_data.content); + + let state = match page_data.state { + Some(state_str) => { + let state_res = serde_json::from_str::(&state_str); + match state_res { + Ok(state) => Some(state), + Err(err) => todo!("serialization error unimplemented") + } + }, + None => None + }; + + // Hydrate that static code using the acquired state + // BUG (Sycamore): this will double-render if the component is just text (no nodes) + sycamore::hydrate_to( + || template_fn(state), + &container.get::().inner_element() + ); + })); + + // This is where the static content will be rendered + // BUG: white flash of death until Sycamore can suspend the router until the static content is ready + template! { + div(ref = container) + } +}