diff --git a/examples/hackernews_axum/src/handlers.rs b/examples/hackernews_axum/src/handlers.rs index 9fe238822..4ed9ff7b2 100644 --- a/examples/hackernews_axum/src/handlers.rs +++ b/examples/hackernews_axum/src/handlers.rs @@ -11,7 +11,6 @@ if #[cfg(feature = "ssr")] { pub async fn file_handler(uri: Uri) -> Result, (StatusCode, String)> { let res = get_static_file(uri.clone(), "/pkg").await?; - println!("FIRST URI{:?}", uri); if res.status() == StatusCode::NOT_FOUND { // try with `.html` @@ -27,7 +26,6 @@ if #[cfg(feature = "ssr")] { pub async fn get_static_file_handler(uri: Uri) -> Result, (StatusCode, String)> { let res = get_static_file(uri.clone(), "/static").await?; - println!("FIRST URI{:?}", uri); if res.status() == StatusCode::NOT_FOUND { Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid URI".to_string())) @@ -41,7 +39,6 @@ if #[cfg(feature = "ssr")] { // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` // When run normally, the root should be the crate root - println!("Base: {:#?}", base); if base == "/static" { match ServeDir::new("./static").oneshot(req).await { Ok(res) => Ok(res.map(boxed)), diff --git a/examples/todo_app_sqlite_axum/Cargo.toml b/examples/todo_app_sqlite_axum/Cargo.toml index 01e633646..df11980ad 100644 --- a/examples/todo_app_sqlite_axum/Cargo.toml +++ b/examples/todo_app_sqlite_axum/Cargo.toml @@ -18,6 +18,7 @@ leptos = { path = "../../../leptos/leptos", default-features = false, features = leptos_axum = { path = "../../../leptos/integrations/axum", default-features = false, optional = true } leptos_meta = { path = "../../../leptos/meta", default-features = false } leptos_router = { path = "../../../leptos/router", default-features = false } +leptos_reactive = { path = "../../../leptos/leptos_reactive", default-features = false } log = "0.4.17" simple_logger = "4.0.0" serde = { version = "1.0.148", features = ["derive"] } diff --git a/examples/todo_app_sqlite_axum/src/main.rs b/examples/todo_app_sqlite_axum/src/main.rs index 615c0416c..638cc3f22 100644 --- a/examples/todo_app_sqlite_axum/src/main.rs +++ b/examples/todo_app_sqlite_axum/src/main.rs @@ -4,15 +4,31 @@ use leptos::*; cfg_if! { if #[cfg(feature = "ssr")] { use axum::{ - routing::post, - extract::Extension, + routing::{post, get}, + extract::{Extension, Path}, + http::Request, + body::StreamBody, + response::{IntoResponse, Response}, Router, }; + use axum::body::Body as AxumBody; use crate::todo::*; use todo_app_sqlite_axum::*; use crate::fallback::file_and_error_handler; use leptos_axum::{generate_route_list, LeptosRoutes}; use std::sync::Arc; + use leptos_reactive::run_scope; + + //Define a handler to test extractor with state + async fn custom_handler(Path(id): Path, Extension(options): Extension>, req: Request) -> Response{ + let handler = leptos_axum::render_app_to_stream_with_context((*options).clone(), + move |cx| { + provide_context(cx, id.clone()); + }, + |cx| view! { cx, } + ); + handler(req).await.into_response() + } #[tokio::main] async fn main() { @@ -35,6 +51,7 @@ if #[cfg(feature = "ssr")] { // build our application with a route let app = Router::new() .route("/api/*fn_name", post(leptos_axum::handle_server_fns)) + .route("/special/:id", get(custom_handler)) .leptos_routes(leptos_options.clone(), routes, |cx| view! { cx, } ) .fallback(file_and_error_handler) .layer(Extension(Arc::new(leptos_options))); diff --git a/examples/todo_app_sqlite_axum/src/todo.rs b/examples/todo_app_sqlite_axum/src/todo.rs index 72c42d919..fff94c215 100644 --- a/examples/todo_app_sqlite_axum/src/todo.rs +++ b/examples/todo_app_sqlite_axum/src/todo.rs @@ -107,6 +107,7 @@ pub async fn delete_todo(id: u16) -> Result<(), ServerFnError> { #[component] pub fn TodoApp(cx: Scope) -> impl IntoView { + let id = use_context::(cx); provide_meta_context(cx); view! { cx, @@ -122,7 +123,6 @@ pub fn TodoApp(cx: Scope) -> impl IntoView { cx, }/> - diff --git a/integrations/actix/src/lib.rs b/integrations/actix/src/lib.rs index c39674f78..9c7e45337 100644 --- a/integrations/actix/src/lib.rs +++ b/integrations/actix/src/lib.rs @@ -115,9 +115,28 @@ pub async fn redirect(cx: leptos::Scope, path: &str) { /// # } /// ``` pub fn handle_server_fns() -> Route { + handle_server_fns_with_context(|_cx| {}) +} + +/// An Actix [Route](actix_web::Route) that listens for a `POST` request with +/// Leptos server function arguments in the body, runs the server function if found, +/// and returns the resulting [HttpResponse]. +/// +/// This provides the [HttpRequest] to the server [Scope](leptos::Scope). +/// +/// This can then be set up at an appropriate route in your application: +/// +/// This version allows you to pass in a closure that adds additional route data to the +/// context, allowing you to pass in info about the route or user from Actix, or other info +pub fn handle_server_fns_with_context( + additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send, +) -> Route { web::post().to( - |req: HttpRequest, params: web::Path, body: web::Bytes| async move { - { + move |req: HttpRequest, params: web::Path, body: web::Bytes| { + let additional_context = additional_context.clone(); + async move { + let additional_context = additional_context.clone(); + let path = params.into_inner(); let accept_header = req .headers() @@ -129,6 +148,9 @@ pub fn handle_server_fns() -> Route { let runtime = create_runtime(); let (cx, disposer) = raw_scope_and_disposer(runtime); + + // Add additional info to the context of the server function + additional_context(cx); let res_options = ResponseOptions::default(); // provide HttpRequest as context in server scope @@ -252,12 +274,29 @@ pub fn render_app_to_stream( options: LeptosOptions, app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static, ) -> Route +where + IV: IntoView, +{ + render_app_to_stream_with_context(options, |_cx| {}, app_fn) +} + +/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries +/// to route it using [leptos_router], serving an HTML stream of your application. +/// +/// This function allows you to provide additional information to Leptos for your route. +/// It could be used to pass in Path Info, Connection Info, or anything your heart desires. +pub fn render_app_to_stream_with_context( + options: LeptosOptions, + additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send, + app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static, +) -> Route where IV: IntoView, { web::get().to(move |req: HttpRequest| { let options = options.clone(); let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); let res_options = ResponseOptions::default(); async move { @@ -272,7 +311,7 @@ where let (head, tail) = html_parts(&options); - stream_app(app, head, tail, res_options).await + stream_app(app, head, tail, res_options, additional_context).await } }) } @@ -355,7 +394,7 @@ where let (head, tail) = html_parts(&options); - stream_app(app, head, tail, res_options).await + stream_app(app, head, tail, res_options, |_cx| {}).await } }) } @@ -385,13 +424,18 @@ async fn stream_app( head: String, tail: String, res_options: ResponseOptions, + additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send, ) -> HttpResponse { - let (stream, runtime, _) = render_to_stream_with_prefix_undisposed(app, move |cx| { - let head = use_context::(cx) - .map(|meta| meta.dehydrate()) - .unwrap_or_default(); - format!("{head}").into() - }); + let (stream, runtime, _) = render_to_stream_with_prefix_undisposed_with_context( + app, + move |cx| { + let head = use_context::(cx) + .map(|meta| meta.dehydrate()) + .unwrap_or_default(); + format!("{head}").into() + }, + additional_context, + ); let mut stream = Box::pin( futures::stream::once(async move { head.clone() }) diff --git a/integrations/axum/src/lib.rs b/integrations/axum/src/lib.rs index 63613dbdf..f081f62c1 100644 --- a/integrations/axum/src/lib.rs +++ b/integrations/axum/src/lib.rs @@ -253,6 +253,137 @@ pub async fn handle_server_fns( rx.await.unwrap() } +/// An Axum handlers to listens for a request with Leptos server function arguments in the body, +/// run the server function if found, and return the resulting [Response]. +/// +/// This provides an `Arc<[Request](axum::http::Request)>` [Scope](leptos::Scope). +/// +/// This can then be set up at an appropriate route in your application: +/// +/// This version allows you to pass in a closure to capture additional data from the layers above leptos +/// and store it in context. To use it, you'll need to define your own route, and a handler function +/// that takes in the data you'd like. See the `render_app_to_stream_with_context()` docs for an example +/// of one that should work much like this one +pub async fn handle_server_fns_with_context( + Path(fn_name): Path, + headers: HeaderMap, + additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send, + req: Request, +) -> impl IntoResponse { + // Axum Path extractor doesn't remove the first slash from the path, while Actix does + let fn_name: String = match fn_name.strip_prefix('/') { + Some(path) => path.to_string(), + None => fn_name, + }; + + let (tx, rx) = futures::channel::oneshot::channel(); + spawn_blocking({ + move || { + tokio::runtime::Runtime::new() + .expect("couldn't spawn runtime") + .block_on({ + async move { + let res = if let Some(server_fn) = server_fn_by_path(fn_name.as_str()) { + let runtime = create_runtime(); + let (cx, disposer) = raw_scope_and_disposer(runtime); + + additional_context(cx); + + let req_parts = generate_request_parts(req).await; + // Add this so we can get details about the Request + provide_context(cx, req_parts.clone()); + // Add this so that we can set headers and status of the response + provide_context(cx, ResponseOptions::default()); + + match server_fn(cx, &req_parts.body).await { + Ok(serialized) => { + // If ResponseOptions are set, add the headers and status to the request + let res_options = use_context::(cx); + + // clean up the scope, which we only needed to run the server fn + disposer.dispose(); + runtime.dispose(); + + // if this is Accept: application/json then send a serialized JSON response + let accept_header = + headers.get("Accept").and_then(|value| value.to_str().ok()); + let mut res = Response::builder(); + + // Add headers from ResponseParts if they exist. These should be added as long + // as the server function returns an OK response + let res_options_outer = res_options.unwrap().0; + let res_options_inner = res_options_outer.read().await; + let (status, mut res_headers) = ( + res_options_inner.status, + res_options_inner.headers.clone(), + ); + + if let Some(header_ref) = res.headers_mut() { + header_ref.extend(res_headers.drain()); + }; + + if accept_header == Some("application/json") + || accept_header + == Some("application/x-www-form-urlencoded") + || accept_header == Some("application/cbor") + { + res = res.status(StatusCode::OK); + } + // otherwise, it's probably a
submit or something: redirect back to the referrer + else { + let referer = headers + .get("Referer") + .and_then(|value| value.to_str().ok()) + .unwrap_or("/"); + + res = res + .status(StatusCode::SEE_OTHER) + .header("Location", referer); + } + // Override StatusCode if it was set in a Resource or Element + res = match status { + Some(status) => res.status(status), + None => res, + }; + match serialized { + Payload::Binary(data) => res + .header("Content-Type", "application/cbor") + .body(Full::from(data)), + Payload::Url(data) => res + .header( + "Content-Type", + "application/x-www-form-urlencoded", + ) + .body(Full::from(data)), + Payload::Json(data) => res + .header("Content-Type", "application/json") + .body(Full::from(data)), + } + } + Err(e) => Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Full::from(e.to_string())), + } + } else { + Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Full::from( + format!("Could not find a server function at the route {fn_name}. \ + \n\nIt's likely that you need to call ServerFn::register() on the \ + server function type, somewhere in your `main` function." ) + )) + } + .expect("could not build Response"); + + _ = tx.send(res); + } + }) + } + }); + + rx.await.unwrap() +} + pub type PinnedHtmlStream = Pin> + Send>>; /// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries @@ -485,6 +616,216 @@ where } } +/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries +/// to route it using [leptos_router], serving an HTML stream of your application. +/// +/// This version allows us to pass Axum State/Extension/Extractor or other infro from Axum or network +/// layers above Leptos itself. To use it, you'll need to write your own handler function that provides +/// the data to leptos in a closure. An example is below +/// ```ignore +/// async fn custom_handler(Path(id): Path, Extension(options): Extension>, req: Request) -> Response{ +/// let handler = leptos_axum::render_app_to_stream_with_context((*options).clone(), +/// move |cx| { +/// provide_context(cx, id.clone()); +/// }, +/// |cx| view! { cx, } +/// ); +/// handler(req).await.into_response() +/// } +/// ``` +/// Otherwise, this function is identical to the `render_app_with_stream() function, which has more info about how this works.` + +pub fn render_app_to_stream_with_context( + options: LeptosOptions, + additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send, + app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static, +) -> impl Fn( + Request, +) -> Pin>> + Send + 'static>> + + Clone + + Send + + 'static +where + IV: IntoView, +{ + move |req: Request| { + Box::pin({ + let options = options.clone(); + let app_fn = app_fn.clone(); + let add_context = additional_context.clone(); + let default_res_options = ResponseOptions::default(); + let res_options2 = default_res_options.clone(); + let res_options3 = default_res_options.clone(); + + async move { + // Need to get the path and query string of the Request + // For reasons that escape me, if the incoming URI protocol is https, it provides the absolute URI + // if http, it returns a relative path. Adding .path() seems to make it explicitly return the relative uri + let path = req.uri().path_and_query().unwrap().as_str(); + + let full_path = format!("http://leptos.dev{path}"); + + let pkg_path = &options.site_pkg_dir; + let output_name = &options.output_name; + + // Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options + // we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME + // Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless + let mut wasm_output_name = output_name.clone(); + if std::env::var("LEPTOS_OUTPUT_NAME").is_err() { + wasm_output_name.push_str("_bg"); + } + + let site_ip = &options.site_address.ip().to_string(); + let reload_port = options.reload_port; + + let leptos_autoreload = match std::env::var("LEPTOS_WATCH").is_ok() { + true => format!( + r#" + + "# + ), + false => "".to_string(), + }; + + let head = format!( + r#" + + + + + + + + {leptos_autoreload} + "# + ); + let tail = ""; + + let (mut tx, rx) = futures::channel::mpsc::channel(8); + + spawn_blocking({ + let app_fn = app_fn.clone(); + let add_context = add_context.clone(); + move || { + tokio::runtime::Runtime::new() + .expect("couldn't spawn runtime") + .block_on({ + let app_fn = app_fn.clone(); + let add_context = add_context.clone(); + async move { + tokio::task::LocalSet::new() + .run_until(async { + let app = { + let full_path = full_path.clone(); + let req_parts = generate_request_parts(req).await; + move |cx| { + let integration = ServerIntegration { + path: full_path.clone(), + }; + provide_context( + cx, + RouterIntegrationContext::new(integration), + ); + provide_context(cx, MetaContext::new()); + provide_context(cx, req_parts); + provide_context(cx, default_res_options); + app_fn(cx).into_view(cx) + } + }; + + let (bundle, runtime, scope) = + render_to_stream_with_prefix_undisposed_with_context( + app, + |cx| { + let head = use_context::(cx) + .map(|meta| meta.dehydrate()) + .unwrap_or_default(); + format!("{head}").into() + }, + add_context, + ); + let mut shell = Box::pin(bundle); + while let Some(fragment) = shell.next().await { + _ = tx.send(fragment).await; + } + + // Extract the value of ResponseOptions from here + let cx = Scope { runtime, id: scope }; + let res_options = + use_context::(cx).unwrap(); + + let new_res_parts = res_options.0.read().await.clone(); + + let mut writable = res_options2.0.write().await; + *writable = new_res_parts; + + runtime.dispose(); + + tx.close_channel(); + }) + .await; + } + }); + } + }); + + let mut stream = Box::pin( + futures::stream::once(async move { head.clone() }) + .chain(rx) + .chain(futures::stream::once(async { tail.to_string() })) + .map(|html| Ok(Bytes::from(html))), + ); + + // Get the first, second, and third chunks in the stream, which renders the app shell, and thus allows Resources to run + let first_chunk = stream.next().await; + let second_chunk = stream.next().await; + let third_chunk = stream.next().await; + + // Extract the resources now that they've been rendered + let res_options = res_options3.0.read().await; + + let complete_stream = futures::stream::iter([ + first_chunk.unwrap(), + second_chunk.unwrap(), + third_chunk.unwrap(), + ]) + .chain(stream); + + let mut res = Response::new(StreamBody::new( + Box::pin(complete_stream) as PinnedHtmlStream + )); + + if let Some(status) = res_options.status { + *res.status_mut() = status + } + let mut res_headers = res_options.headers.clone(); + res.headers_mut().extend(res_headers.drain()); + + res + } + }) + } +} + /// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically /// create routes in Axum's Router without having to use wildcard matching or fallbacks. Takes in your root app Element /// as an argument so it can walk you app tree. This version is tailored to generate Axum compatible paths. diff --git a/leptos_dom/src/ssr.rs b/leptos_dom/src/ssr.rs index 9e92101d4..4ac6cd6c7 100644 --- a/leptos_dom/src/ssr.rs +++ b/leptos_dom/src/ssr.rs @@ -96,6 +96,29 @@ pub fn render_to_stream_with_prefix( pub fn render_to_stream_with_prefix_undisposed( view: impl FnOnce(Scope) -> View + 'static, prefix: impl FnOnce(Scope) -> Cow<'static, str> + 'static, +) -> (impl Stream, RuntimeId, ScopeId) { + render_to_stream_with_prefix_undisposed_with_context(view, prefix, |_cx| {}) +} + +/// Renders a function to a stream of HTML strings and returns the [Scope] and [Runtime] that were created, so +/// they can be disposed when appropriate. After the `view` runs, the `prefix` will run with +/// the same scope. This can be used to generate additional HTML that has access to the same `Scope`. +/// +/// This renders: +/// 1) the prefix +/// 2) the application shell +/// a) HTML for everything that is not under a ``, +/// b) the `fallback` for any `` component that is not already resolved, and +/// c) JavaScript necessary to receive streaming [Resource](leptos_reactive::Resource) data. +/// 3) streaming [Resource](leptos_reactive::Resource) data. Resources begin loading on the +/// server and are sent down to the browser to resolve. On the browser, if the app sees that +/// it is waiting for a resource to resolve from the server, it doesn't run it initially. +/// 4) HTML fragments to replace each `` fallback with its actual data as the resources +/// read under that `` resolve. +pub fn render_to_stream_with_prefix_undisposed_with_context( + view: impl FnOnce(Scope) -> View + 'static, + prefix: impl FnOnce(Scope) -> Cow<'static, str> + 'static, + additional_context: impl FnOnce(Scope) + 'static, ) -> (impl Stream, RuntimeId, ScopeId) { HydrationCtx::reset_id(); @@ -108,6 +131,8 @@ pub fn render_to_stream_with_prefix_undisposed( _, ) = run_scope_undisposed(runtime, { move |cx| { + // Add additional context items + additional_context(cx); // the actual app body/template code // this does NOT contain any of the data being loaded asynchronously in resources let shell = view(cx).render_to_string(cx);