From 1ea5e62a6fa35c321788ffbb790a58a60e3540b8 Mon Sep 17 00:00:00 2001 From: siddhantCodes Date: Mon, 13 Oct 2025 15:33:35 +0530 Subject: [PATCH 1/6] feat: Support for catch-all 404/500 pages If a fastn package contains a 404.ftd file then visiting any 404 route (a route that does not exist) will render this 404.ftd file. For any other fastn error (error in syntax, calls to http processor failing etc), the 500.ftd in current fastn package will be rendered. when env DEBUG is set, the original error with a nicer message is shown instead to give the developer some more context. In production, the `--trace` output should be considered around these 404/500 requests to know about the cause. --- fastn-core/src/commands/serve.rs | 279 +++++++++++++++----------- fastn-core/src/package/package_doc.rs | 40 ++-- 2 files changed, 174 insertions(+), 145 deletions(-) diff --git a/fastn-core/src/commands/serve.rs b/fastn-core/src/commands/serve.rs index 92ac57380a..2aeb615654 100644 --- a/fastn-core/src/commands/serve.rs +++ b/fastn-core/src/commands/serve.rs @@ -19,46 +19,25 @@ async fn serve_file( path: &camino::Utf8Path, only_js: bool, preview_session_id: &Option, -) -> fastn_core::http::Response { - if let Err(e) = config +) -> fastn_core::Result { + config .config .package - .auto_import_language(config.request.cookie("fastn-lang"), None) - { - return if config.config.test_command_running { - fastn_core::http::not_found_without_warning(format!("fastn-Error: path: {path}, {e:?}")) - } else { - fastn_core::not_found!("fastn-Error: path: {}, {:?}", path, e) - }; - } + .auto_import_language(config.request.cookie("fastn-lang"), None)?; - let f = match config + let f = config .get_file_and_package_by_id(path.as_str(), preview_session_id) - .await - { - Ok(f) => f, - Err(e) => { - tracing::error!( - msg = "fastn-error path not found", - path = path.as_str(), - error = %e - ); - return if config.config.test_command_running { - fastn_core::http::not_found_without_warning(format!( - "fastn-Error: path: {path}, {e:?}" - )) - } else { - fastn_core::not_found!("fastn-Error: path: {}, {:?}", path, e) - }; - } - }; + .await?; tracing::info!("file: {f:?}"); if let fastn_core::File::Code(doc) = f { let path = doc.get_full_path().to_string(); let mime = mime_guess::from_path(path).first_or_text_plain(); - return fastn_core::http::ok_with_content_type(doc.content.into_bytes(), mime); + return Ok(fastn_core::http::ok_with_content_type( + doc.content.into_bytes(), + mime, + )); } let main_document = match f { @@ -66,11 +45,13 @@ async fn serve_file( _ => { tracing::error!(msg = "unknown handler", path = path.as_str()); tracing::info!("file: {f:?}"); - return fastn_core::server_error!("unknown handler"); + return Err(fastn_core::Error::GenericError( + "unknown handler".to_string(), + )); } }; - match fastn_core::package::package_doc::read_ftd_( + let ftd_res = fastn_core::package::package_doc::read_ftd_( config, &main_document, "/", @@ -79,81 +60,73 @@ async fn serve_file( only_js, preview_session_id, ) - .await - { - Ok(val) => match val { - fastn_core::package::package_doc::FTDResult::Html(body) => { - fastn_core::http::ok_with_content_type(body, mime_guess::mime::TEXT_HTML_UTF_8) - } - fastn_core::package::package_doc::FTDResult::Response { - response, - status_code, - content_type, - headers, - } => { - use std::str::FromStr; - - let mut response = actix_web::HttpResponseBuilder::new(status_code) - .content_type(content_type) - .body(response); - - for (header_name, header_value) in headers { - let header_name = - match actix_web::http::header::HeaderName::from_str(header_name.as_str()) { - Ok(v) => v, - Err(e) => { - tracing::error!( - msg = "fastn-Error", - path = path.as_str(), - error = e.to_string() - ); - continue; - } - }; - - let header_value = - match actix_web::http::header::HeaderValue::from_str(header_value.as_str()) - { - Ok(v) => v, - Err(e) => { - tracing::error!( - msg = "fastn-Error", - path = path.as_str(), - error = e.to_string() - ); - continue; - } - }; - - response.headers_mut().insert(header_name, header_value); - } + .await?; - response - } - fastn_core::package::package_doc::FTDResult::Redirect { url, code } => { - if Some(mime_guess::mime::APPLICATION_JSON) == config.request.content_type() { - fastn_core::http::ok_with_content_type( - // intentionally using `.unwrap()` as this should never fail - serde_json::to_vec(&serde_json::json!({ "redirect": url })).unwrap(), - mime_guess::mime::APPLICATION_JSON, - ) - } else { - fastn_core::http::redirect_with_code(url, code) - } + let res = match ftd_res { + fastn_core::package::package_doc::FTDResult::Html(body) => { + fastn_core::http::ok_with_content_type(body, mime_guess::mime::TEXT_HTML_UTF_8) + } + fastn_core::package::package_doc::FTDResult::Response { + response, + status_code, + content_type, + headers, + } => { + use std::str::FromStr; + + let mut response = actix_web::HttpResponseBuilder::new(status_code) + .content_type(content_type) + .body(response); + + for (header_name, header_value) in headers { + let header_name = + match actix_web::http::header::HeaderName::from_str(header_name.as_str()) { + Ok(v) => v, + Err(e) => { + tracing::error!( + msg = "fastn-Error", + path = path.as_str(), + error = e.to_string() + ); + continue; + } + }; + + let header_value = + match actix_web::http::header::HeaderValue::from_str(header_value.as_str()) { + Ok(v) => v, + Err(e) => { + tracing::error!( + msg = "fastn-Error", + path = path.as_str(), + error = e.to_string() + ); + continue; + } + }; + + response.headers_mut().insert(header_name, header_value); } - fastn_core::package::package_doc::FTDResult::Json(json) => { - fastn_core::http::ok_with_content_type(json, mime_guess::mime::APPLICATION_JSON) + + response + } + fastn_core::package::package_doc::FTDResult::Redirect { url, code } => { + if Some(mime_guess::mime::APPLICATION_JSON) == config.request.content_type() { + fastn_core::http::ok_with_content_type( + // intentionally using `.unwrap()` as this should never fail + serde_json::to_vec(&serde_json::json!({ "redirect": url })).unwrap(), + mime_guess::mime::APPLICATION_JSON, + ) + } else { + fastn_core::http::redirect_with_code(url, code) } - }, - Err(e) => { - tracing::error!( - msg = "fastn-Error", - path = path.as_str(), - error = e.to_string() - ); - fastn_core::server_error!("fastn-Error: path: {}, {:?}", path, e) } - } + fastn_core::package::package_doc::FTDResult::Json(json) => { + fastn_core::http::ok_with_content_type(json, mime_guess::mime::APPLICATION_JSON) + } + }; + + Ok(res) } fn guess_mime_type(path: &str) -> mime_guess::Mime { @@ -212,7 +185,7 @@ pub async fn serve( } if fastn_core::utils::is_static_path(req.path()) { - return handle_static_route( + let res = handle_static_route( req.path(), config.package.name.as_str(), &config.ds, @@ -220,11 +193,80 @@ pub async fn serve( ) .await .map(|r| (r, true)); + + return match res { + Ok(v) => Ok(v), + Err(e) => { + handle_error( + Err(fastn_core::Error::DSReadError(e)), + &mut req_config, + preview_session_id, + ) + .await + } + }; } - serve_helper(&mut req_config, only_js, path, preview_session_id) + let res = serve_helper(&mut req_config, only_js, path, preview_session_id) .await - .map(|r| (r, req_config.response_is_cacheable)) + .map(|r| (r, req_config.response_is_cacheable)); + + match res { + Ok(v) => Ok(v), + Err(e) => handle_error(Err(e), &mut req_config, preview_session_id).await, + } +} + +/// Handle [fastn_core::Error]. Possibly converting some of them to proper HTTP responses. +/// +/// Attempts to load 404.ftd/500.ftd if present. +/// The actual error message is shown if env DEBUG=true. +#[inline] +async fn handle_error( + res: fastn_core::Result, + req_config: &mut fastn_core::RequestConfig, + preview_session_id: &Option, +) -> fastn_core::Result<(fastn_core::http::Response, bool)> { + match res { + Err(err) => { + tracing::error!(?err, "handle_error"); + + if req_config.config.ds.env("DEBUG").await.is_ok() { + tracing::info!("DEBUG mode is on, returning error as response"); + return Err(err); + } + + let (file_to_render, status_code) = match err { + fastn_core::Error::NotFound(_) + | fastn_core::Error::DSReadError(fastn_ds::ReadError::NotFound(_)) => { + ("/404.ftd", fastn_core::http::StatusCode::NOT_FOUND) + } + _ => ( + "/500.ftd", + fastn_core::http::StatusCode::INTERNAL_SERVER_ERROR, + ), + }; + + match serve_file( + req_config, + &camino::Utf8PathBuf::from(file_to_render), + false, + preview_session_id, + ) + .await + { + Ok(mut res) => { + *res.status_mut() = status_code; + Ok((res, false)) // response is not cacheable + } + Err(e) => { + tracing::info!(?e, "Failed to load 404.ftd/500.ftd"); + Err(err) // return the original error + } + } + } + _ => unreachable!("This function is only called on error"), + } } #[tracing::instrument(skip_all)] @@ -235,7 +277,7 @@ pub async fn serve_helper( preview_session_id: &Option, ) -> fastn_core::Result { let mut resp = if req_config.request.path() == "/" { - serve_file(req_config, &path.join("/"), only_js, preview_session_id).await + serve_file(req_config, &path.join("/"), only_js, preview_session_id).await? } else { // url is present in config or not // If not present than proxy pass it @@ -287,7 +329,7 @@ pub async fn serve_helper( } let file_response = - serve_file(req_config, path.as_path(), only_js, preview_session_id).await; + serve_file(req_config, path.as_path(), only_js, preview_session_id).await?; tracing::info!( "before executing proxy: file-status: {}, path: {}", @@ -415,13 +457,14 @@ async fn handle_static_route( package_name: &str, ds: &fastn_ds::DocumentStore, session_id: &Option, -) -> fastn_core::Result { +) -> Result { return match handle_static_route_(path, package_name, ds, session_id).await { Ok(r) => Ok(r), Err(fastn_ds::ReadError::NotFound(_)) => { + // try a dark variant if this is an image handle_not_found_image(path, package_name, ds, session_id).await } - Err(e) => Err(e.into()), + Err(e) => Err(e), }; async fn handle_static_route_( @@ -457,21 +500,15 @@ async fn handle_static_route( package_name: &str, ds: &fastn_ds::DocumentStore, session_id: &Option, - ) -> fastn_core::Result { + ) -> Result { // todo: handle dark images using manifest if let Some(new_file_path) = generate_dark_image_path(path) { - return handle_static_route_(new_file_path.as_str(), package_name, ds, session_id) - .await - .or_else(|e| { - if let fastn_ds::ReadError::NotFound(e) = e { - Ok(fastn_core::http::not_found_without_warning(e)) - } else { - Err(e.into()) - } - }); + return handle_static_route_(&new_file_path, package_name, ds, session_id).await; } - Ok(fastn_core::http::not_found_without_warning("".to_string())) + Err(fastn_ds::ReadError::NotFound(format!( + "file not found: {path}" + ))) } fn generate_dark_image_path(path: &str) -> Option { diff --git a/fastn-core/src/package/package_doc.rs b/fastn-core/src/package/package_doc.rs index dc863c63ed..afc0a7781b 100644 --- a/fastn-core/src/package/package_doc.rs +++ b/fastn-core/src/package/package_doc.rs @@ -98,12 +98,10 @@ impl fastn_core::Package { document = id, package = self.name ); - Err(fastn_core::Error::PackageError { - message: format!( - "fs_fetch_by_id:: Corresponding file not found for id: {}. Package: {}", - id, &self.name - ), - }) + Err(fastn_core::Error::NotFound(format!( + "fs_fetch_by_id:: Corresponding file not found for id: {}. Package: {}", + id, &self.name + ))) } // #[cfg(feature = "use-config-json")] @@ -187,12 +185,10 @@ impl fastn_core::Package { file_path = file_path, msg = "file_path error: can not get the dark" ); - return Err(fastn_core::Error::PackageError { - message: format!( - "fs_fetch_by_file_name:: Corresponding file not found for file_path: {}. Package: {}", - file_path, &self.name - ), - }); + return Err(fastn_core::Error::NotFound(format!( + "fs_fetch_by_file_name:: Corresponding file not found for file_path: {}. Package: {}", + file_path, &self.name + ))); } }; @@ -201,12 +197,10 @@ impl fastn_core::Package { file_path = file_path, msg = "file_path error: can not get the dark" ); - return Err(fastn_core::Error::PackageError { - message: format!( - "fs_fetch_by_file_name:: Corresponding file not found for file_path: {}. Package: {}", - file_path, &self.name - ), - }); + return Err(fastn_core::Error::NotFound(format!( + "fs_fetch_by_file_name:: Corresponding file not found for file_path: {}. Package: {}", + file_path, &self.name + ))); } new_file_path @@ -255,12 +249,10 @@ impl fastn_core::Package { } _ => { tracing::error!(id = id, msg = "id error: can not get the dark"); - return Err(fastn_core::Error::PackageError { - message: format!( - "fs_fetch_by_id:: Corresponding file not found for id: {}. Package: {} 1", - id, &self.name - ), - }); + return Err(fastn_core::Error::NotFound(format!( + "fs_fetch_by_id:: Corresponding file not found for id: {}. Package: {} 1", + id, &self.name + ))); } }; From 4f73a5a64d4f3394b35906900ccc2461b1211e9c Mon Sep 17 00:00:00 2001 From: siddhantCodes Date: Mon, 13 Oct 2025 15:36:40 +0530 Subject: [PATCH 2/6] minor: correct description based on usage of DEBUG env --- fastn-core/src/commands/serve.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastn-core/src/commands/serve.rs b/fastn-core/src/commands/serve.rs index 2aeb615654..0d154fc046 100644 --- a/fastn-core/src/commands/serve.rs +++ b/fastn-core/src/commands/serve.rs @@ -220,7 +220,7 @@ pub async fn serve( /// Handle [fastn_core::Error]. Possibly converting some of them to proper HTTP responses. /// /// Attempts to load 404.ftd/500.ftd if present. -/// The actual error message is shown if env DEBUG=true. +/// The actual error message is shown if env DEBUG is set. #[inline] async fn handle_error( res: fastn_core::Result, From 6bc0b1486a0523254bc9a8a4c5ede80c230963b8 Mon Sep 17 00:00:00 2001 From: siddhantCodes Date: Mon, 13 Oct 2025 15:45:20 +0530 Subject: [PATCH 3/6] doc: catch-all error handling pages --- fastn.com/FASTN.ftd | 4 ++++ fastn.com/best-practices/error-handling.ftd | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 fastn.com/best-practices/error-handling.ftd diff --git a/fastn.com/FASTN.ftd b/fastn.com/FASTN.ftd index 2cbf6af99c..40a8c8cfb5 100644 --- a/fastn.com/FASTN.ftd +++ b/fastn.com/FASTN.ftd @@ -839,6 +839,8 @@ skip: true document: best-practices/self-referencing.ftd - Property: /book/property-guidelines/ document: best-practices/property-guidelines.ftd + - Error Handling: /book/error-handling/ + document: best-practices/error-handling.ftd ## Frontend: /ftd/data-modelling/ @@ -1003,6 +1005,8 @@ skip: true document: best-practices/self-referencing.ftd - Property: /property-guidelines/ document: best-practices/property-guidelines.ftd + - Error Handling: /error-handling/ + document: best-practices/error-handling.ftd ## Fullstack: /dynamic-urls/ document: backend/dynamic-urls.ftd diff --git a/fastn.com/best-practices/error-handling.ftd b/fastn.com/best-practices/error-handling.ftd new file mode 100644 index 0000000000..4763ace8e2 --- /dev/null +++ b/fastn.com/best-practices/error-handling.ftd @@ -0,0 +1,19 @@ +-- import: bling.fifthtry.site/note + +-- ds.page: Error Handling + +`fastn` considers `404.ftd` and `500.ftd` in your fastn project folder as +special files. + +If a fastn package contains a `404.ftd` file then visiting any 404 route (a +route that does not exist) will render this 404.ftd file. + +For any other fastn error (error in syntax, calls to http processor failing +etc), the 500.ftd in current fastn package will be rendered. + +When env `DEBUG` is set, the original error with a nicer message is shown +instead to give the developer some more context. In production, the `fastn +--trace serve` output should be considered around these 404/500 requests to +know more about the cause. + +-- end: ds.page From 8f48e896634010004bb682309cdf4cd0d0403a5a Mon Sep 17 00:00:00 2001 From: siddhantCodes Date: Mon, 13 Oct 2025 15:52:16 +0530 Subject: [PATCH 4/6] fix(fastn.com): sitemap so that doc links load fine Links from the book overwrite original doc links. This may result in some of the book links giving 404 pages. This is fixed by prefixing every Book route with /book/. --- fastn.com/FASTN.ftd | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/fastn.com/FASTN.ftd b/fastn.com/FASTN.ftd index 40a8c8cfb5..b4ff7eefb5 100644 --- a/fastn.com/FASTN.ftd +++ b/fastn.com/FASTN.ftd @@ -617,7 +617,8 @@ skip: true ## `fastn` Made Easy: /book/ -- Introduction: +- Introduction: /book/why-fastn/ +document: book/01-introduction/00-why-fastn.ftd - Why was `fastn` created?: /book/why-fastn/ document: book/01-introduction/00-why-fastn.ftd - FifthTry Offerings: /book/fifthtry/ @@ -646,7 +647,8 @@ skip: true document: author/how-to/vscode.ftd - First Program - Hello World: /book/hello-world/ document: book/01-introduction/03-hello-world.ftd -- FifthTry Online Editor (IDE) +- FifthTry Online Editor (IDE): /book/create-website/ +document: book/01-introduction/05-create-website.ftd - Develop and Preview in FifthTry IDE: /book/create-website/ document: book/01-introduction/05-create-website.ftd - FifthTry Domain Setting and Hosting: /book/fifthtry-hosting/ @@ -815,9 +817,9 @@ skip: true document: best-practices/formatting.ftd - Import: /book/importing-packages/ document: best-practices/import.ftd - - Auto-import: /auto-import/ + - Auto-import: /book/auto-import/ document: /book/best-practices/auto-import.ftd - - Device: /device-guidelines/ + - Device: /book/device-guidelines/ document: best-practices/device.ftd - Conditions: /book/how-to-use-conditions/ document: best-practices/use-conditions.ftd From d3042fb00ac6df4d4e2535dc285d10d1c9d19c2f Mon Sep 17 00:00:00 2001 From: siddhantCodes Date: Mon, 13 Oct 2025 15:57:53 +0530 Subject: [PATCH 5/6] Prepare for 0.4.114 release --- Cargo.lock | 2 +- Changelog.md | 6 ++++++ fastn/Cargo.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9fd96336f1..39adf71cb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "fastn" -version = "0.4.113" +version = "0.4.114" dependencies = [ "actix-web", "camino", diff --git a/Changelog.md b/Changelog.md index 597d46151b..2c19f05049 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,11 @@ # `fastn` Change Log +## 13 October 2025 + +### fastn: 0.4.114 + +- feat: Support for catch-all 404/500 pages. PR #2214. + ## 17 September 2025 ### fastn: 0.4.113 diff --git a/fastn/Cargo.toml b/fastn/Cargo.toml index 8b3eed62b2..cf20035f1c 100644 --- a/fastn/Cargo.toml +++ b/fastn/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fastn" -version = "0.4.113" +version = "0.4.114" authors.workspace = true edition.workspace = true license.workspace = true From 52d9d217e42e3bea15b2ecf42ff00f51b954260b Mon Sep 17 00:00:00 2001 From: siddhantCodes Date: Wed, 29 Oct 2025 10:30:17 +0530 Subject: [PATCH 6/6] refactor: handle_error works with just the Error --- fastn-core/src/commands/serve.rs | 78 ++++++++++++++------------------ 1 file changed, 33 insertions(+), 45 deletions(-) diff --git a/fastn-core/src/commands/serve.rs b/fastn-core/src/commands/serve.rs index 0d154fc046..958fa6d043 100644 --- a/fastn-core/src/commands/serve.rs +++ b/fastn-core/src/commands/serve.rs @@ -196,14 +196,7 @@ pub async fn serve( return match res { Ok(v) => Ok(v), - Err(e) => { - handle_error( - Err(fastn_core::Error::DSReadError(e)), - &mut req_config, - preview_session_id, - ) - .await - } + Err(e) => handle_error(e.into(), &mut req_config, preview_session_id).await, }; } @@ -213,7 +206,7 @@ pub async fn serve( match res { Ok(v) => Ok(v), - Err(e) => handle_error(Err(e), &mut req_config, preview_session_id).await, + Err(e) => handle_error(e, &mut req_config, preview_session_id).await, } } @@ -223,49 +216,44 @@ pub async fn serve( /// The actual error message is shown if env DEBUG is set. #[inline] async fn handle_error( - res: fastn_core::Result, + err: fastn_core::Error, req_config: &mut fastn_core::RequestConfig, preview_session_id: &Option, ) -> fastn_core::Result<(fastn_core::http::Response, bool)> { - match res { - Err(err) => { - tracing::error!(?err, "handle_error"); + tracing::error!(?err, "handle_error"); - if req_config.config.ds.env("DEBUG").await.is_ok() { - tracing::info!("DEBUG mode is on, returning error as response"); - return Err(err); - } + if req_config.config.ds.env("DEBUG").await.is_ok() { + tracing::info!("DEBUG mode is on, returning error as response"); + return Err(err); + } - let (file_to_render, status_code) = match err { - fastn_core::Error::NotFound(_) - | fastn_core::Error::DSReadError(fastn_ds::ReadError::NotFound(_)) => { - ("/404.ftd", fastn_core::http::StatusCode::NOT_FOUND) - } - _ => ( - "/500.ftd", - fastn_core::http::StatusCode::INTERNAL_SERVER_ERROR, - ), - }; + let (file_to_render, status_code) = match err { + fastn_core::Error::NotFound(_) + | fastn_core::Error::DSReadError(fastn_ds::ReadError::NotFound(_)) => { + ("/404.ftd", fastn_core::http::StatusCode::NOT_FOUND) + } + _ => ( + "/500.ftd", + fastn_core::http::StatusCode::INTERNAL_SERVER_ERROR, + ), + }; - match serve_file( - req_config, - &camino::Utf8PathBuf::from(file_to_render), - false, - preview_session_id, - ) - .await - { - Ok(mut res) => { - *res.status_mut() = status_code; - Ok((res, false)) // response is not cacheable - } - Err(e) => { - tracing::info!(?e, "Failed to load 404.ftd/500.ftd"); - Err(err) // return the original error - } - } + match serve_file( + req_config, + &camino::Utf8PathBuf::from(file_to_render), + false, + preview_session_id, + ) + .await + { + Ok(mut res) => { + *res.status_mut() = status_code; + Ok((res, false)) // response is not cacheable + } + Err(e) => { + tracing::info!(?e, "Failed to load 404.ftd/500.ftd"); + Err(err) // return the original error } - _ => unreachable!("This function is only called on error"), } }