leptos_spin/
lib.rs

1use futures::{SinkExt,Stream,StreamExt};
2use leptos::{provide_context, use_context, LeptosOptions, RuntimeId};
3use leptos_router::RouteListing;
4use route_table::RouteMatch;
5use spin_sdk::http::{Headers, IncomingRequest, OutgoingResponse, ResponseOutparam};
6pub mod request;
7pub mod request_parts;
8pub mod response;
9pub mod response_options;
10pub mod route_table;
11pub mod server_fn;
12
13use crate::server_fn::handle_server_fns_with_context;
14pub use request_parts::RequestParts;
15pub use response_options::ResponseOptions;
16pub use route_table::RouteTable;
17use leptos::server_fn::redirect::REDIRECT_HEADER;
18
19pub async fn render_best_match_to_stream<IV>(
20    req: IncomingRequest,
21    resp_out: ResponseOutparam,
22    routes: &RouteTable,
23    app_fn: impl Fn() -> IV + 'static + Clone,
24    leptos_opts: &LeptosOptions,
25) where
26    IV: leptos::IntoView + 'static,
27{
28    render_best_match_to_stream_with_context(req, resp_out, routes, app_fn, || {},  leptos_opts).await;
29}
30pub async fn render_best_match_to_stream_with_context<IV>(
31    req: IncomingRequest,
32    resp_out: ResponseOutparam,
33    routes: &RouteTable,
34    app_fn: impl Fn() -> IV + 'static + Clone,
35    additional_context: impl Fn() + Clone + Send + 'static,
36    leptos_opts: &LeptosOptions,
37) where
38    IV: leptos::IntoView + 'static,
39{
40    // req.uri() doesn't provide the full URI on Cloud (https://github.com/fermyon/spin/issues/2110). For now, use the header instead
41    let url = url::Url::parse(&url(&req)).unwrap();
42    let path = url.path();
43
44    match routes.best_match(path) {
45        RouteMatch::Route(best_listing) => {
46            render_route_with_context(url, req, resp_out, app_fn, additional_context, leptos_opts, &best_listing).await
47        }
48        RouteMatch::ServerFn => handle_server_fns_with_context(req, resp_out, additional_context).await,
49        RouteMatch::None => {
50            eprintln!("No route found for {url}");
51            not_found(resp_out).await
52        }
53    }
54}
55
56async fn render_route<IV>(
57    url: url::Url,
58    req: IncomingRequest,
59    resp_out: ResponseOutparam,
60    app_fn: impl Fn() -> IV + 'static + Clone,
61    leptos_opts: &LeptosOptions,
62    listing: &RouteListing,
63) where
64    IV: leptos::IntoView + 'static,
65{
66    render_route_with_context(url, req, resp_out, app_fn, ||{}, leptos_opts, listing).await;
67}
68
69async fn render_route_with_context<IV>(
70    url: url::Url,
71    req: IncomingRequest,
72    resp_out: ResponseOutparam,
73    app_fn: impl Fn() -> IV + 'static + Clone,
74    additional_context: impl Fn() + Clone + Send + 'static,
75    leptos_opts: &LeptosOptions,
76    listing: &RouteListing,
77) where
78    IV: leptos::IntoView + 'static,
79{
80    if listing.static_mode().is_some() {
81        log_and_server_error("Static routes are not supported on Spin", resp_out);
82        return;
83    }
84
85    match listing.mode() {
86        leptos_router::SsrMode::OutOfOrder => {
87            let resp_opts = ResponseOptions::default();
88            let req_parts = RequestParts::new_from_req(&req);
89
90            let app = {
91                let app_fn2 = app_fn.clone();
92                let res_options = resp_opts.clone();
93                move || {
94                    provide_contexts(&url, req_parts, res_options, additional_context);
95                    (app_fn2)().into_view()
96                }
97            };
98            render_view_into_response_stm(app, resp_opts, leptos_opts, resp_out).await;
99        }
100        leptos_router::SsrMode::Async => {
101            let resp_opts = ResponseOptions::default();
102            let req_parts = RequestParts::new_from_req(&req);
103
104            let app = {
105                let app_fn2 = app_fn.clone();
106                let res_options = resp_opts.clone();
107                move || {
108                    provide_contexts(&url, req_parts, res_options, additional_context);
109                    (app_fn2)().into_view()
110                }
111            };
112            render_view_into_response_stm_async_mode(app, resp_opts, leptos_opts, resp_out).await;
113        }
114        leptos_router::SsrMode::InOrder => {
115            let resp_opts = ResponseOptions::default();
116            let req_parts = RequestParts::new_from_req(&req);
117
118            let app = {
119                let app_fn2 = app_fn.clone();
120                let res_options = resp_opts.clone();
121                move || {
122                    provide_contexts(&url, req_parts, res_options, additional_context);
123                    (app_fn2)().into_view()
124                }
125            };
126            render_view_into_response_stm_in_order_mode(app, leptos_opts, resp_opts, resp_out)
127                .await;
128        }
129        leptos_router::SsrMode::PartiallyBlocked => {
130            let resp_opts = ResponseOptions::default();
131            let req_parts = RequestParts::new_from_req(&req);
132
133            let app = {
134                let app_fn2 = app_fn.clone();
135                let res_options = resp_opts.clone();
136                move || {
137
138                    provide_contexts(&url, req_parts, res_options, additional_context);
139                    (app_fn2)().into_view()
140                }
141            };
142            render_view_into_response_stm_partially_blocked_mode(
143                app,
144                leptos_opts,
145                resp_opts,
146                resp_out,
147            )
148            .await;
149        }
150    }
151}
152
153// This is a backstop - the app should normally include a "/*"" route
154// mapping to a NotFound Leptos component.
155async fn not_found(resp_out: ResponseOutparam) {
156    let og = OutgoingResponse::new(Headers::new());
157    og.set_status_code(404).expect("Failed to set status'");
158    resp_out.set(og);
159}
160
161async fn render_view_into_response_stm(
162    app: impl FnOnce() -> leptos::View + 'static,
163    resp_opts: ResponseOptions,
164    leptos_opts: &leptos::leptos_config::LeptosOptions,
165    resp_out: ResponseOutparam,
166) {
167    let (stm, runtime) = leptos::leptos_dom::ssr::render_to_stream_with_prefix_undisposed_with_context_and_block_replacement(
168        app,
169        || leptos_meta::generate_head_metadata_separated().1.into(),
170        || {},
171        false);
172    build_stream_response(stm, leptos_opts, resp_opts, resp_out, runtime).await;
173}
174
175async fn render_view_into_response_stm_async_mode(
176    app: impl FnOnce() -> leptos::View + 'static,
177    resp_opts: ResponseOptions,
178    leptos_opts: &leptos::leptos_config::LeptosOptions,
179    resp_out: ResponseOutparam,
180) {
181    // In the Axum integration, all this happens in a separate task, and sends back
182    // to the function via a futures::channel::oneshot(). WASI doesn't have an
183    // equivalent for that yet, so for now, just truck along.
184    let (stm, runtime) = leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
185        app,
186        move || "".into(),
187        || {},
188    );
189    let html = leptos_integration_utils::build_async_response(stm, leptos_opts, runtime).await;
190
191    let status_code = resp_opts.status().unwrap_or(200);
192    let headers = resp_opts.headers();
193
194    let og = OutgoingResponse::new(headers);
195    og.set_status_code(status_code).expect("Failed to set status");
196    let mut ogbod = og.take_body();
197    resp_out.set(og);
198    ogbod.send(html.into_bytes()).await.unwrap();
199}
200
201async fn render_view_into_response_stm_in_order_mode(
202    app: impl FnOnce() -> leptos::View + 'static,
203    leptos_opts: &LeptosOptions,
204    resp_opts: ResponseOptions,
205    resp_out: ResponseOutparam,
206) {
207    let (stm, runtime) = leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
208        app,
209        || leptos_meta::generate_head_metadata_separated().1.into(),
210        || {},
211    );
212
213    build_stream_response(stm, leptos_opts, resp_opts, resp_out, runtime).await;
214}
215
216async fn render_view_into_response_stm_partially_blocked_mode(
217    app: impl FnOnce() -> leptos::View + 'static,
218    leptos_opts: &LeptosOptions,
219    resp_opts: ResponseOptions,
220    resp_out: ResponseOutparam,
221) {
222    let (stm, runtime) =
223        leptos::ssr::render_to_stream_with_prefix_undisposed_with_context_and_block_replacement(
224            app,
225            move || leptos_meta::generate_head_metadata_separated().1.into(),
226            || (),
227            true,
228        );
229    build_stream_response(stm, leptos_opts, resp_opts, resp_out, runtime).await;
230}
231
232async fn build_stream_response(
233    stm: impl Stream<Item = String>,
234    leptos_opts: &LeptosOptions,
235    resp_opts: ResponseOptions,
236    resp_out: ResponseOutparam,
237    runtime: RuntimeId,
238) {
239    let mut stm2 = Box::pin(stm);
240
241    let first_app_chunk = stm2.next().await.unwrap_or_default();
242    let (head, tail) = leptos_integration_utils::html_parts_separated(
243        leptos_opts,
244        leptos::use_context::<leptos_meta::MetaContext>().as_ref(),
245    );
246
247    let mut stm3 = Box::pin(
248        futures::stream::once(async move { head.clone() })
249            .chain(futures::stream::once(async move { first_app_chunk }).chain(stm2))
250            .map(|html| html.into_bytes()),
251    );
252
253    let first_chunk = stm3.next().await;
254    let second_chunk = stm3.next().await;
255
256    let status_code = resp_opts.status().unwrap_or(200);
257    let headers = resp_opts.headers();
258
259    let og = OutgoingResponse::new(headers);
260    og.set_status_code(status_code).expect("Failed to set status");
261    let mut ogbod = og.take_body();
262    resp_out.set(og);
263
264    let mut stm4 = Box::pin(
265        futures::stream::iter([first_chunk.unwrap(), second_chunk.unwrap()])
266            .chain(stm3)
267            .chain(
268                futures::stream::once(async move {
269                    runtime.dispose();
270                    tail.to_string()
271                })
272                .map(|html| html.into_bytes()),
273            ),
274    );
275
276    while let Some(ch) = stm4.next().await {
277        ogbod.send(ch).await.unwrap();
278    }
279}
280
281/// Provides an easy way to redirect the user from within a server function.
282///
283/// This sets the `Location` header to the URL given.
284///
285/// If the route or server function in which this is called is being accessed
286/// by an ordinary `GET` request or an HTML `<form>` without any enhancement, it also sets a
287/// status code of `302` for a temporary redirect. (This is determined by whether the `Accept`
288/// header contains `text/html` as it does for an ordinary navigation.)
289///
290/// Otherwise, it sets a custom header that indicates to the client that it should redirect,
291/// without actually setting the status code. This means that the client will not follow the
292/// redirect, and can therefore return the value of the server function and then handle
293/// the redirect with client-side routing.
294pub fn redirect(path: &str) {
295    if let (Some(req), Some(res)) = (
296        use_context::<RequestParts>(),
297        use_context::<ResponseOptions>(),
298    ) {
299        // insert the Location header in any case
300        res.insert_header("location", path);
301
302        let req_headers = Headers::from_list(req.headers()).expect("Failed to construct Headers from Request Headers");
303        let accepts_html = &req_headers.get(&"Accept".to_string())[0];
304        let accepts_html_bool = String::from_utf8_lossy(accepts_html).contains("text/html");
305        if accepts_html_bool {
306            // if the request accepts text/html, it's a plain form request and needs
307            // to have the 302 code set
308            res.set_status(302);
309        } else {
310            // otherwise, we sent it from the server fn client and actually don't want
311            // to set a real redirect, as this will break the ability to return data
312            // instead, set the REDIRECT_HEADER to indicate that the client should redirect
313            res.insert_header(REDIRECT_HEADER, "");
314        }
315    } else {
316        tracing::warn!(
317            "Couldn't retrieve either Parts or ResponseOptions while trying \
318             to redirect()."
319        );
320    }
321}
322
323fn provide_contexts(url: &url::Url, req_parts: RequestParts, res_options: ResponseOptions, additional_context: impl Fn() + Clone + Send + 'static) {
324    let path = leptos_corrected_path(url);
325
326    let integration = leptos_router::ServerIntegration { path };
327    provide_context(leptos_router::RouterIntegrationContext::new(integration));
328    provide_context(leptos_meta::MetaContext::new());
329    provide_context(res_options);
330    provide_context(req_parts);
331    additional_context();
332    leptos_router::provide_server_redirect(redirect);
333    #[cfg(feature = "nonce")]
334    leptos::nonce::provide_nonce();
335}
336
337fn leptos_corrected_path(req: &url::Url) -> String {
338    let path = req.path();
339    let query = req.query();
340    if query.unwrap_or_default().is_empty() {
341        "http://leptos".to_string() + path
342    } else {
343        "http://leptos".to_string() + path + "?" + query.unwrap_or_default()
344    }
345}
346
347fn url(req: &IncomingRequest) -> String {
348    let full_url = &req.headers().get(&"spin-full-url".to_string())[0];
349    String::from_utf8_lossy(full_url).to_string()
350}
351
352fn log_and_server_error(message: impl Into<String>, resp_out: ResponseOutparam) {
353    println!("Error: {}", message.into());
354    let response = spin_sdk::http::OutgoingResponse::new(spin_sdk::http::Fields::new());
355    response.set_status_code(500).expect("Failed to set status");
356    resp_out.set(response);
357}