diff --git a/examples/showcase/app/src/lib.rs b/examples/showcase/app/src/lib.rs index a4983e6f0f..197d032702 100644 --- a/examples/showcase/app/src/lib.rs +++ b/examples/showcase/app/src/lib.rs @@ -3,7 +3,7 @@ pub mod pages; use sycamore::prelude::*; use sycamore_router::{Route, BrowserRouter}; use wasm_bindgen::prelude::*; -use perseus::shell::app_shell; +use perseus::shell::{app_shell, ErrorPages}; // Define our routes #[derive(Route)] @@ -30,6 +30,20 @@ enum AppRoute { NotFound } +fn get_error_pages() -> ErrorPages { + let mut error_pages = ErrorPages::new(Box::new(|_, _, _| template! { + p { "Another error occurred." } + })); + error_pages.add_page(404, Box::new(|_, _, _| template! { + p { "Page not found." } + })); + error_pages.add_page(400, Box::new(|_, _, _| template! { + p { "Client error occurred..." } + })); + + error_pages +} + // This is deliberately purely client-side rendered #[wasm_bindgen] pub fn run() -> Result<(), JsValue> { @@ -47,34 +61,42 @@ pub fn run() -> Result<(), JsValue> { || template! { BrowserRouter(|route: AppRoute| { + // TODO improve performance rather than naively copying error pages for every template match route { AppRoute::Index => app_shell( "index".to_string(), - pages::index::template_fn() + pages::index::template_fn(), + get_error_pages() ), AppRoute::About => app_shell( "about".to_string(), - pages::about::template_fn() + pages::about::template_fn(), + get_error_pages() ), AppRoute::Post { slug } => app_shell( format!("post/{}", slug), - pages::post::template_fn() + pages::post::template_fn(), + get_error_pages() ), AppRoute::NewPost => app_shell( "post/new".to_string(), - pages::new_post::template_fn() + pages::new_post::template_fn(), + get_error_pages() ), AppRoute::Ip => app_shell( "ip".to_string(), - pages::ip::template_fn() + pages::ip::template_fn(), + get_error_pages() ), AppRoute::Time { slug } => app_shell( format!("timeisr/{}", slug), - pages::time::template_fn() + pages::time::template_fn(), + get_error_pages() ), AppRoute::TimeRoot => app_shell( "time".to_string(), - pages::time_root::template_fn() + pages::time_root::template_fn(), + get_error_pages() ), AppRoute::NotFound => template! { p {"Not Found."} diff --git a/examples/showcase/app/src/pages/ip.rs b/examples/showcase/app/src/pages/ip.rs index d44785a705..a3adfd80ef 100644 --- a/examples/showcase/app/src/pages/ip.rs +++ b/examples/showcase/app/src/pages/ip.rs @@ -3,6 +3,7 @@ use serde::{Serialize, Deserialize}; use sycamore::prelude::{template, component, GenericNode, Template as SycamoreTemplate}; use perseus::template::Template; +use perseus::errors::ErrorCause; #[derive(Serialize, Deserialize)] pub struct IpPageProps { @@ -26,7 +27,8 @@ pub fn get_page() -> Template { .template(template_fn()) } -pub async fn get_request_state(_path: String) -> Result { +pub async fn get_request_state(_path: String) -> Result { + // Err(("this is a test error!".to_string(), ErrorCause::Client(None))) Ok(serde_json::to_string( &IpPageProps { ip: "x.x.x.x".to_string() diff --git a/examples/showcase/app/src/pages/post.rs b/examples/showcase/app/src/pages/post.rs index a301674c8e..dd0d739e65 100644 --- a/examples/showcase/app/src/pages/post.rs +++ b/examples/showcase/app/src/pages/post.rs @@ -44,7 +44,7 @@ pub async fn get_static_props(path: String) -> Result { } ).unwrap()) } -// TODO + pub async fn get_static_paths() -> Result, String> { Ok(vec![ "test".to_string() diff --git a/examples/showcase/server/src/main.rs b/examples/showcase/server/src/main.rs index 84934cc4a0..ebc87d86b5 100644 --- a/examples/showcase/server/src/main.rs +++ b/examples/showcase/server/src/main.rs @@ -1,4 +1,4 @@ -use actix_web::{web, App, HttpRequest, HttpServer, Result as ActixResult, error}; +use actix_web::{web, App, HttpRequest, HttpServer, HttpResponse, http::StatusCode}; use actix_files::{NamedFile}; use sycamore::prelude::SsrNode; use std::collections::HashMap; @@ -6,7 +6,9 @@ use std::collections::HashMap; use perseus::{ serve::{get_render_cfg, get_page}, config_manager::FsConfigManager, - template::TemplateMap + template::TemplateMap, + errors::ErrorKind as PerseusErr, + errors::err_to_status_code }; use perseus_showcase_app::pages; @@ -53,12 +55,15 @@ async fn page_data( templates: web::Data>, render_cfg: web::Data>, config_manager: web::Data -) -> ActixResult { +) -> HttpResponse { let path = req.match_info().query("filename"); - // TODO match different types of errors here - let page_data = get_page(path, &render_cfg, &templates, config_manager.get_ref()).await.map_err(error::ErrorNotFound)?; + let page_data = get_page(path, &render_cfg, &templates, config_manager.get_ref()).await; + let http_res = match page_data { + Ok(page_data) => HttpResponse::Ok().body( + serde_json::to_string(&page_data).unwrap() + ), + Err(err) => HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap()).body(err.to_string()), + }; - Ok( - serde_json::to_string(&page_data).unwrap() - ) + http_res } diff --git a/src/errors.rs b/src/errors.rs index cb82b80e93..65ba60d7ac 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -3,39 +3,64 @@ pub use error_chain::bail; use error_chain::error_chain; +/// Defines who caused an ambiguous error message so we can reliably create an HTTP status code. Specific status codes may be provided +/// in either case, or the defaults (400 for client, 500 for server) will be used. +#[derive(Debug)] +pub enum ErrorCause { + Client(Option), + Server(Option) +} + +// TODO disclose what information may be revealed over the network through these errors in docs // The `error_chain` setup for the whole crate error_chain! { // The custom errors for this crate (very broad) errors { - /// For indistinct JavaScript errors. + /// For indistinct JavaScript errors (potentially sensitive, but only generated on the client-side). JsErr(err: String) { description("an error occurred while interfacing with javascript") display("the following error occurred while interfacing with javascript: {:?}", err) } + /// For when a fetched URL didn't return a string, which it must. + AssetNotString(url: String) { + description("the fetched asset wasn't a string") + display("the fetched asset at '{}' wasn't a string", url) + } + /// For when the server returned a non-200 error code (not including 404, that's handled separately). + AssetNotOk(url: String, status: u16, err: String) { + description("the asset couldn't be fecthed with a 200 OK") + display("the asset at '{}' returned status code '{}' with payload '{}'", url, status, err) + } + /// For when a necessary template feautre was expected but not present. This just pertains to rendering strategies, and shouldn't + /// ever be sensitive. TemplateFeatureNotEnabled(name: String, feature: String) { description("a template feature required by a function called was not present") display("the template '{}' is missing the feature '{}'", name, feature) } + /// For when the given path wasn't found, a 404 should never be sensitive. PageNotFound(path: String) { description("the requested page was not found") display("the requested page at path '{}' was not found", path) } - NoRenderOpts(template_path: String) { - description("a template had no rendering options for use at request-time") - display("the template '{}' had no rendering options for use at request-time", template_path) - } + /// For when the user misconfigured their revalidation length, which should be caught at build time, and hence shouldn't be + /// sensitive. InvalidDatetimeIntervalIndicator(indicator: String) { description("invalid indicator in timestring") display("invalid indicator '{}' in timestring, must be one of: s, m, h, d, w, M, y", indicator) } + /// For when a template defined both build and request states when it can't amalgamate them sensibly, which indicates a misconfiguration. + /// Revealing the rendering strategies of a template in this way should never be sensitive. Due to the execution context, this + /// doesn't disclose the offending template. BothStatesDefined { description("both build and request states were defined for a template when only one or fewer were expected") display("both build and request states were defined for a template when only one or fewer were expected") } - RenderFnFailed(fn_name: String, template: String, err_str: String) { + /// For when a render function failed. Only request-time functions can generate errors that will be transmitted over the network, + /// so **render functions must not disclose sensitive information in errors**. Other information shouldn't be sensitive. + RenderFnFailed(fn_name: String, template: String, cause: ErrorCause, err_str: String) { description("error while calling render function") - display("an error occurred while calling render function '{}' on template '{}': '{}'", fn_name, template, err_str) + display("an error caused by '{:?}' occurred while calling render function '{}' on template '{}': '{}'", cause, fn_name, template, err_str) } } links { @@ -48,3 +73,31 @@ error_chain! { ChronoParse(::chrono::ParseError); } } + +pub fn err_to_status_code(err: &Error) -> u16 { + match err.kind() { + // Misconfiguration + ErrorKind::TemplateFeatureNotEnabled(_, _) => 500, + // Bad request + ErrorKind::PageNotFound(_) => 404, + // Misconfiguration + ErrorKind::InvalidDatetimeIntervalIndicator(_) => 500, + // Misconfiguration + ErrorKind::BothStatesDefined => 500, + // Ambiguous, we'll rely on the given cause + ErrorKind::RenderFnFailed(_, _, cause, _) => match cause { + ErrorCause::Client(code) => code.unwrap_or(400), + ErrorCause::Server(code) => code.unwrap_or(500), + }, + // We shouldn't be generating JS errors on the server... + ErrorKind::JsErr(_) => panic!("function 'err_to_status_code' is only intended for server-side usage"), + // These are nearly always server-induced + ErrorKind::ConfigManager(_) => 500, + ErrorKind::Io(_) => 500, + ErrorKind::ChronoParse(_) => 500, + // JSON errors can be caused by the client, but we don't have enough information + ErrorKind::Json(_) => 500, + // Any other errors go to a 500 + _ => 500 + } +} \ No newline at end of file diff --git a/src/shell.rs b/src/shell.rs index 7bbbe50aea..22d26c9c57 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -3,18 +3,20 @@ use wasm_bindgen::JsCast; use wasm_bindgen_futures::{JsFuture}; use web_sys::{Request, RequestInit, RequestMode, Response}; use sycamore::prelude::*; +use sycamore::prelude::{Template as SycamoreTemplate}; +use std::collections::HashMap; use crate::errors::*; use crate::serve::PageData; use crate::template::TemplateFn; -pub async fn fetch(url: String) -> Result> { +pub async fn fetch(url: &str) -> 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 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 @@ -28,50 +30,115 @@ pub async fn fetch(url: String) -> Result> { // 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)?; + web_sys::console::log_1(&JsValue::from(&body)); // 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()) + let body_str = body.as_string(); + let body_str = match body_str { + Some(body_str) => body_str, + None => bail!(ErrorKind::AssetNotString(url.to_string())) + }; + // Handle non-200 error codes + if res.status() == 200 { + Ok(Some(body_str)) + } else { + bail!(ErrorKind::AssetNotOk(url.to_string(), res.status(), body_str)) + } +} + +/// The callback to a template the user must provide for error pages. This is passed the status code, the error message, and the URL of +/// the problematic asset. +pub type ErrorPageTemplate = Box SycamoreTemplate>; + +/// A type alias for the `HashMap` the user should provide for error pages. +pub struct ErrorPages { + status_pages: HashMap>, + fallback: ErrorPageTemplate +} +impl ErrorPages { + /// Creates a new definition of error pages with just a fallback. + pub fn new(fallback: ErrorPageTemplate) -> Self { + Self { + status_pages: HashMap::default(), + fallback + } + } + pub fn add_page(&mut self, status: u16, page: ErrorPageTemplate) { + self.status_pages.insert(status, page); + } + pub fn render_page(&self, url: &str, status: &u16, err: &str, container: &NodeRef) { + // Check if we have an explicitly defined page for this status code + // If not, we'll render the fallback page + let template_fn = match self.status_pages.contains_key(status) { + true => self.status_pages.get(status).unwrap(), + false => &self.fallback, + }; + // Render that to the given container + sycamore::render_to( + || template_fn(url, status, err), + &container.get::().inner_element() + ); + } } /// 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 set up errors here -pub fn app_shell(path: String, template_fn: TemplateFn) -> Template { +// TODO handle exceptions higher up +pub fn app_shell( + path: String, + template_fn: TemplateFn, + error_pages: ErrorPages +) -> 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") + let asset_url = format!("/.perseus/page/{}", path.to_string()); + // If this doesn't exist, then it's a 404 (we went here by explicit navigation, but it may be an unservable ISR page or the like) + let page_data_str = fetch(&asset_url).await; + match page_data_str { + Ok(page_data_str) => match page_data_str { + Some(page_data_str) => { + // All good, deserialize the page data + let page_data = serde_json::from_str::(&page_data_str); + match page_data { + Ok(page_data) => { + // We have the page data ready, render everything + // 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); + + // 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(page_data.state), + &container.get::().inner_element() + ); + }, + // If the page failed to serialize, an exception has occurred + Err(err) => panic!("page data couldn't be serialized: '{}'", err) + }; + }, + // TODO NotFound + None => error_pages.render_page(&asset_url, &404, "page not found", &container), }, - 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") + Err(err) => match err.kind() { + // TODO NotOk + ErrorKind::AssetNotOk(url, status, err) => error_pages.render_page(url, status, err, &container), + // No other errors should be returned + _ => panic!("expected 'AssetNotOk' error, found other unacceptable error") + } }; - - // 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); - - // 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(page_data.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 + // PageToRender::Success( + // template! { + // div(ref = container) + // } + // ) template! { div(ref = container) } diff --git a/src/template.rs b/src/template.rs index 6cea7ae78f..19fbc3962a 100644 --- a/src/template.rs +++ b/src/template.rs @@ -1,6 +1,4 @@ // This file contains logic to define how templates are rendered -// TODO make all user functions able to return errors -// TODO make all user functions asynchronous use crate::errors::*; use sycamore::prelude::{Template as SycamoreTemplate, GenericNode}; @@ -42,6 +40,8 @@ impl States { /// A generic error type that mandates a string error. This sidesteps horrible generics while maintaining DX. pub type StringResult = std::result::Result; +/// A generic error type that mandates a string errorr and a statement of causation (client or server) for status code generation. +pub type StringResultWithCause = std::result::Result; /// A generic return type for asynchronous functions that we need to store in a struct. type AsyncFnReturn = Pin>>; @@ -87,9 +87,8 @@ macro_rules! make_async_trait { // A series of asynchronous closure traits that prevent the user from having to pin their functions make_async_trait!(GetBuildPathsFnType, StringResult>); make_async_trait!(GetBuildStateFnType, StringResult, path: String); -// TODO add request data to be passed in here -make_async_trait!(GetRequestStateFnType, StringResult, path: String); -make_async_trait!(ShouldRevalidateFnType, StringResult); +make_async_trait!(GetRequestStateFnType, StringResultWithCause, path: String); +make_async_trait!(ShouldRevalidateFnType, StringResultWithCause); // A series of closure types that should not be typed out more than once pub type TemplateFn = Box) -> SycamoreTemplate>; @@ -97,7 +96,7 @@ pub type GetBuildPathsFn = Box; pub type GetBuildStateFn = Box; pub type GetRequestStateFn = Box; pub type ShouldRevalidateFn = Box; -pub type AmalgamateStatesFn = Box StringResult>>; +pub type AmalgamateStatesFn = Box StringResultWithCause>>; /// This allows the specification of all the template templates in an app and how to render them. If no rendering logic is provided at all, /// the template will be prerendered at build-time with no state. All closures are stored on the heap to avoid hellish lifetime specification. @@ -140,7 +139,6 @@ pub struct Template /// uses both `build_state` and `request_state`. If not specified and both are generated, request state will be prioritized. amalgamate_states: Option } -// TODO mandate usage conditions (e.g. ISR needs SSG) impl Template { /// Creates a new template definition. pub fn new(path: impl Into + std::fmt::Display) -> Self { @@ -168,7 +166,12 @@ impl Template { let res = get_build_paths.call().await; match res { Ok(res) => Ok(res), - Err(err) => bail!(ErrorKind::RenderFnFailed("get_build_paths".to_string(), self.get_path(), err.to_string())) + Err(err) => bail!(ErrorKind::RenderFnFailed( + "get_build_paths".to_string(), + self.get_path(), + ErrorCause::Server(None), + err.to_string() + )) } } else { bail!(ErrorKind::TemplateFeatureNotEnabled(self.path.clone(), "build_paths".to_string())) @@ -181,45 +184,68 @@ impl Template { let res = get_build_state.call(path).await; match res { Ok(res) => Ok(res), - Err(err) => bail!(ErrorKind::RenderFnFailed("get_build_state".to_string(), self.get_path(), err.to_string())) + Err(err) => bail!(ErrorKind::RenderFnFailed( + "get_build_state".to_string(), + self.get_path(), + ErrorCause::Server(None), + err.to_string() + )) } } else { bail!(ErrorKind::TemplateFeatureNotEnabled(self.path.clone(), "build_state".to_string())) } } /// Gets the request-time state for a template. This is equivalent to SSR, and will not be performed at build-time. Unlike - /// `.get_build_paths()` though, this will be passed information about the request that triggered the render. + /// `.get_build_paths()` though, this will be passed information about the request that triggered the render. Errors here can be caused + /// by either the server or the client, so the user must specify an [`ErrorCause`]. pub async fn get_request_state(&self, path: String) -> Result { if let Some(get_request_state) = &self.get_request_state { let res = get_request_state.call(path).await; match res { Ok(res) => Ok(res), - Err(err) => bail!(ErrorKind::RenderFnFailed("get_request_state".to_string(), self.get_path(), err.to_string())) + Err((err, cause)) => bail!(ErrorKind::RenderFnFailed( + "get_request_state".to_string(), + self.get_path(), + cause, + err.to_string() + )) } } else { bail!(ErrorKind::TemplateFeatureNotEnabled(self.path.clone(), "request_state".to_string())) } } - /// Amalagmates given request and build states. + /// Amalagmates given request and build states. Errors here can be caused by either the server or the client, so the user must specify + /// an [`ErrorCause`]. pub fn amalgamate_states(&self, states: States) -> Result> { if let Some(amalgamate_states) = &self.amalgamate_states { let res = amalgamate_states(states); match res { Ok(res) => Ok(res), - Err(err) => bail!(ErrorKind::RenderFnFailed("amalgamate_states".to_string(), self.get_path(), err.to_string())) + Err((err, cause)) => bail!(ErrorKind::RenderFnFailed( + "amalgamate_states".to_string(), + self.get_path(), + cause, + err.to_string() + )) } } else { bail!(ErrorKind::TemplateFeatureNotEnabled(self.path.clone(), "request_state".to_string())) } } /// Checks, by the user's custom logic, if this template should revalidate. This function isn't presently parsed anything, but has - /// network access etc., and can really do whatever it likes. + /// network access etc., and can really do whatever it likes. Errors here can be caused by either the server or the client, so the + /// user must specify an [`ErrorCause`]. pub async fn should_revalidate(&self) -> Result { if let Some(should_revalidate) = &self.should_revalidate { let res = should_revalidate.call().await; match res { Ok(res) => Ok(res), - Err(err) => bail!(ErrorKind::RenderFnFailed("should_revalidate".to_string(), self.get_path(), err.to_string())) + Err((err, cause)) => bail!(ErrorKind::RenderFnFailed( + "should_revalidate".to_string(), + self.get_path(), + cause, + err.to_string() + )) } } else { bail!(ErrorKind::TemplateFeatureNotEnabled(self.path.clone(), "should_revalidate".to_string()))