├── .github └── workflows │ └── main.yaml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── Makefile.toml ├── README.md ├── rustfmt.toml └── src ├── executor.rs ├── handler.rs ├── lib.rs ├── request.rs ├── response.rs └── utils.rs /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | CARGO_TERM_COLOR: always 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | make: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Install cargo-make 20 | uses: actions-rs/cargo@v1 21 | with: 22 | command: install 23 | args: --debug cargo-make 24 | - name: Run CI 25 | uses: actions-rs/cargo@v1 26 | with: 27 | command: make 28 | args: ci-flow 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | #.idea/ -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "leptos_wasi" 3 | authors = ["Enzo Nocera"] 4 | license = "MIT" 5 | repository = "https://github.com/leptos-rs/leptos_wasi" 6 | description = "WASI integrations for the Leptos web framework." 7 | version = "0.1.3" 8 | edition = "2021" 9 | 10 | [dependencies] 11 | throw_error = { version = "0.2.0" } 12 | hydration_context = { version = "0.2.0" } 13 | futures = "0.3.30" 14 | wasi = "0.13.1+wasi-0.2.0" 15 | leptos = { version = "0.7.0", features = ["nonce", "ssr"] } 16 | leptos_meta = { version = "0.7.0", features = ["ssr"] } 17 | leptos_router ={ version = "0.7.0", features = ["ssr"] } 18 | leptos_macro ={ version = "0.7.0", features = ["generic"] } 19 | leptos_integration_utils = { version = "0.7.0" } 20 | server_fn = { version = "0.7.0", features = ["generic"] } 21 | http = "1.1.0" 22 | parking_lot = "0.12.3" 23 | bytes = "1.7.2" 24 | routefinder = "0.5.4" 25 | mime_guess = "2.0" 26 | thiserror = "2" 27 | 28 | [features] 29 | islands-router = [] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Leptos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | CARGO_MAKE_RUN_CHECK_FORMAT = true 3 | CARGO_MAKE_RUN_CLIPPY = true 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

leptos_wasi

3 | 4 |

5 | Run your Leptos Server-Side in 6 | WebAssembly 7 | using WASI standards. 8 | 9 |

10 |
11 | 12 | ## Explainer 13 | 14 | WebAssembly is already popular in the browser but organisations like the 15 | [Bytecode Alliance][bc-a] are committed to providing the industry with new 16 | standard-driven ways of running software. Specifically, they are maintaining 17 | the [Wasmtime][wasmtime] runtime, which allows running WebAssembly out of the 18 | browser (e.g., on a serverless platform). 19 | 20 | Leptos is already leveraging WebAssembly in the browser and gives you tools to 21 | build web applications with best-in-class performance. 22 | 23 | This crate aims to go further and enable you to also leverage WebAssembly for 24 | your [Leptos Server][leptos-server]. Specifically, it will allow you to 25 | target the rust `wasm32-wasip2` target for the server-side while integrating 26 | seamlessly with the Leptos Framework. 27 | 28 | Running `cargo leptos build` will provide you with a 29 | [WebAssembly Component][wasm-component] importing the 30 | [`wasi:http/proxy` world][wasi-http-proxy]. This means you can serve 31 | your server on any runtime supporting this world, for example: 32 | 33 | ```shell 34 | wasmtime serve target/server/wasm32-wasip2/debug/your_crate.wasm -Scommon 35 | ``` 36 | 37 | [bc-a]: https://bytecodealliance.org/ 38 | [leptos-server]: https://book.leptos.dev/server/index.html 39 | [wasmtime]: https://wasmtime.dev 40 | [wasi-http-proxy]: https://github.com/WebAssembly/wasi-http/blob/main/proxy.md 41 | [wasm-component]: https://component-model.bytecodealliance.org 42 | 43 | ## Disclaimer 44 | 45 | This crate is **EXPERIMENTAL** and the author is not affiliated with the Bytecode 46 | Alliance nor funded by any organisation. Consider this crate should become a 47 | community-driven project and be battle-tested to be deemed *production-ready*. 48 | 49 | Contributions are welcome! 50 | 51 | ## Usage 52 | 53 | TODO: Write a template starter for the crate. 54 | 55 | ### Compatibility 56 | 57 | This crate only works with the future **Leptos v0.7**. 58 | 59 | ## Features 60 | 61 | * :octopus: **Async Runtime**: This crate comes with a single-threaded *async* executor 62 | making full use of WASIp2 [`pollable`][wasip2-pollable], so your server is not 63 | blocking on I/O and can benefit from Leptos' streaming [SSR Modes][leptos-ssr-modes]. 64 | * :zap: **Short-circuiting Mechanism**: Your component is smart enough to avoid 65 | preparing or doing any *rendering* work if the request routes to static files or 66 | *Server Functions*. 67 | * :truck: **Custom Static Assets Serving**: You can write your own logic 68 | for serving static assets. For example, once 69 | [`wasi:blobstore`][wasi-blobstore] matures up, you could host your static assets 70 | on your favorite *Object Storage* provider and make your server fetch them 71 | seamlessly. 72 | 73 | [leptos-ssr-modes]: https://book.leptos.dev/ssr/23_ssr_modes.html 74 | [wasip2-pollable]: https://github.com/WebAssembly/wasi-io/blob/main/wit/poll.wit 75 | [wasi-blobstore]: https://github.com/WebAssembly/wasi-blobstore 76 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/leptos-rs/leptos/blob/main/rustfmt.toml 2 | # to follow the same conventions across the organisation. 3 | 4 | # Stable options 5 | edition = "2021" 6 | max_width = 80 7 | 8 | # Unstable options 9 | imports_granularity = "Crate" 10 | format_strings = true 11 | group_imports = "One" 12 | format_code_in_doc_comments = true 13 | -------------------------------------------------------------------------------- /src/executor.rs: -------------------------------------------------------------------------------- 1 | //! This is (Yet Another) Async Runtime for WASI with first-class support 2 | //! for `.await`-ing on [`Pollable`]. It is an ad-hoc implementation 3 | //! tailored for Leptos but it could be exported into a standalone crate. 4 | //! 5 | //! It is based on the `futures` crate's [`LocalPool`] and makes use of 6 | //! no `unsafe` code. 7 | //! 8 | //! # Performance Notes 9 | //! 10 | //! I haven't benchmarked this runtime but since it makes no use of unsafe code 11 | //! and Rust `core`'s `Context` was prematurely optimised for multi-threading 12 | //! environment, I had no choice but using synchronisation primitives to make 13 | //! the API happy. 14 | //! 15 | //! IIRC, `wasm32` targets have an implementation of synchronisation primitives 16 | //! that are just stubs, downgrading them to their single-threaded counterpart 17 | //! so the overhead should be minimal. 18 | //! 19 | //! Also, you can customise the behaviour of the [`Executor`] using the 20 | //! [`Mode`] enum to trade-off reactivity for less host context switch 21 | //! with the [`Mode::Stalled`] variant. 22 | 23 | use futures::{ 24 | channel::mpsc::{UnboundedReceiver, UnboundedSender}, 25 | executor::{LocalPool, LocalSpawner}, 26 | task::{LocalSpawnExt, SpawnExt}, 27 | FutureExt, Stream, 28 | }; 29 | use leptos::task::{any_spawner, CustomExecutor}; 30 | use parking_lot::Mutex; 31 | use std::{ 32 | cell::RefCell, 33 | future::Future, 34 | mem, 35 | rc::Rc, 36 | sync::{Arc, OnceLock}, 37 | task::{Context, Poll, Wake, Waker}, 38 | }; 39 | use wasi::{ 40 | clocks::monotonic_clock::{subscribe_duration, Duration}, 41 | io::poll::{poll, Pollable}, 42 | }; 43 | 44 | struct TableEntry(Pollable, Waker); 45 | 46 | static POLLABLE_SINK: OnceLock> = OnceLock::new(); 47 | 48 | pub async fn sleep(duration: Duration) { 49 | WaitPoll::new(subscribe_duration(duration)).await 50 | } 51 | 52 | pub struct WaitPoll(WaitPollInner); 53 | 54 | enum WaitPollInner { 55 | Unregistered(Pollable), 56 | Registered(Arc), 57 | } 58 | 59 | impl WaitPoll { 60 | pub fn new(pollable: Pollable) -> Self { 61 | Self(WaitPollInner::Unregistered(pollable)) 62 | } 63 | } 64 | 65 | impl Future for WaitPoll { 66 | type Output = (); 67 | 68 | fn poll( 69 | self: std::pin::Pin<&mut Self>, 70 | cx: &mut Context<'_>, 71 | ) -> Poll { 72 | match &mut self.get_mut().0 { 73 | this @ WaitPollInner::Unregistered(_) => { 74 | let waker = Arc::new(WaitPollWaker::new(cx.waker())); 75 | 76 | if let Some(sender) = POLLABLE_SINK.get() { 77 | if let WaitPollInner::Unregistered(pollable) = mem::replace( 78 | this, 79 | WaitPollInner::Registered(waker.clone()), 80 | ) { 81 | sender 82 | .clone() 83 | .unbounded_send(TableEntry(pollable, waker.into())) 84 | .expect("cannot spawn a new WaitPoll"); 85 | 86 | Poll::Pending 87 | } else { 88 | unreachable!(); 89 | } 90 | } else { 91 | panic!( 92 | "cannot create a WaitPoll before creating an Executor" 93 | ); 94 | } 95 | } 96 | WaitPollInner::Registered(waker) => { 97 | let mut lock = waker.0.lock(); 98 | if lock.done { 99 | Poll::Ready(()) 100 | } else { 101 | // How can it happen?! :O 102 | // Well, if, for some reason, the Task get woken up for 103 | // another reason than the pollable associated with this 104 | // WaitPoll got ready. 105 | // 106 | // We need to make sure we update the waker. 107 | lock.task_waker = cx.waker().clone(); 108 | Poll::Pending 109 | } 110 | } 111 | } 112 | } 113 | } 114 | 115 | struct WaitPollWaker(Mutex); 116 | 117 | struct WaitPollWakerInner { 118 | done: bool, 119 | task_waker: Waker, 120 | } 121 | 122 | impl WaitPollWaker { 123 | fn new(waker: &Waker) -> Self { 124 | Self(Mutex::new(WaitPollWakerInner { 125 | done: false, 126 | task_waker: waker.clone(), 127 | })) 128 | } 129 | } 130 | 131 | impl Wake for WaitPollWaker { 132 | fn wake(self: std::sync::Arc) { 133 | self.wake_by_ref(); 134 | } 135 | 136 | fn wake_by_ref(self: &std::sync::Arc) { 137 | let mut lock = self.0.lock(); 138 | lock.task_waker.wake_by_ref(); 139 | lock.done = true; 140 | } 141 | } 142 | 143 | /// Controls how often the [`Executor`] checks for [`Pollable`] readiness. 144 | pub enum Mode { 145 | /// Will check as often as possible for readiness, this have some 146 | /// performance overhead. 147 | Premptive, 148 | 149 | /// Will only check for readiness when no more progress can be made 150 | /// on pooled Futures. 151 | Stalled, 152 | } 153 | 154 | #[derive(Clone)] 155 | pub struct Executor(Rc); 156 | 157 | struct ExecutorInner { 158 | pool: RefCell, 159 | spawner: LocalSpawner, 160 | rx: RefCell>, 161 | mode: Mode, 162 | } 163 | 164 | impl Executor { 165 | pub fn new(mode: Mode) -> Self { 166 | let pool = LocalPool::new(); 167 | let spawner = pool.spawner(); 168 | let (tx, rx) = futures::channel::mpsc::unbounded(); 169 | 170 | POLLABLE_SINK 171 | .set(tx.clone()) 172 | .expect("calling Executor::new two times is not supported"); 173 | 174 | Self(Rc::new(ExecutorInner { 175 | pool: RefCell::new(pool), 176 | spawner, 177 | rx: RefCell::new(rx), 178 | mode, 179 | })) 180 | } 181 | 182 | pub fn run_until(&self, fut: T) -> T::Output 183 | where 184 | T: Future + 'static, 185 | { 186 | let (tx, mut rx) = futures::channel::oneshot::channel::(); 187 | self.spawn_local(Box::pin(fut.then(|val| async move { 188 | if tx.send(val).is_err() { 189 | panic!( 190 | "failed to send the return value of the future passed to \ 191 | run_until" 192 | ); 193 | } 194 | }))); 195 | 196 | loop { 197 | match rx.try_recv() { 198 | Err(_) => panic!( 199 | "internal error: sender of run until has been dropped" 200 | ), 201 | Ok(Some(val)) => return val, 202 | Ok(None) => { 203 | self.poll_local(); 204 | } 205 | } 206 | } 207 | } 208 | } 209 | 210 | impl CustomExecutor for Executor { 211 | fn spawn(&self, fut: any_spawner::PinnedFuture<()>) { 212 | self.0.spawner.spawn(fut).unwrap(); 213 | } 214 | 215 | fn spawn_local(&self, fut: any_spawner::PinnedLocalFuture<()>) { 216 | self.0.spawner.spawn_local(fut).unwrap(); 217 | } 218 | 219 | fn poll_local(&self) { 220 | let mut pool = match self.0.pool.try_borrow_mut() { 221 | Ok(pool) => pool, 222 | // Nested call to poll_local(), noop. 223 | Err(_) => return, 224 | }; 225 | 226 | match self.0.mode { 227 | Mode::Premptive => { 228 | pool.try_run_one(); 229 | } 230 | Mode::Stalled => pool.run_until_stalled(), 231 | }; 232 | 233 | let (lower, upper) = self.0.rx.borrow().size_hint(); 234 | let capacity = upper.unwrap_or(lower); 235 | let mut entries = Vec::with_capacity(capacity); 236 | let mut rx = self.0.rx.borrow_mut(); 237 | 238 | loop { 239 | match rx.try_next() { 240 | Ok(None) => break, 241 | Ok(Some(entry)) => { 242 | entries.push(Some(entry)); 243 | } 244 | Err(_) => break, 245 | } 246 | } 247 | 248 | if entries.is_empty() { 249 | // This could happen if some Futures use Waker that are not 250 | // registered through [`WaitPoll`] or that we are blocked 251 | // because some Future returned `Poll::Pending` without 252 | // actually making sure their Waker is called at some point. 253 | return; 254 | } 255 | 256 | let pollables = entries 257 | .iter() 258 | .map(|entry| &entry.as_ref().unwrap().0) 259 | .collect::>(); 260 | 261 | let ready = poll(&pollables); 262 | 263 | if let Some(sender) = POLLABLE_SINK.get() { 264 | let sender = sender.clone(); 265 | 266 | // Wakes futures subscribed to ready pollable. 267 | for index in ready { 268 | let wake = entries[index as usize].take().unwrap().1; 269 | wake.wake(); 270 | } 271 | 272 | // Requeue not ready pollable. 273 | for entry in entries.into_iter().flatten() { 274 | sender 275 | .unbounded_send(entry) 276 | .expect("the sender channel is closed"); 277 | } 278 | } else { 279 | unreachable!(); 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | 3 | use crate::{ 4 | response::{Body, Response, ResponseOptions}, 5 | utils::redirect, 6 | CHUNK_BYTE_SIZE, 7 | }; 8 | use bytes::Bytes; 9 | use futures::{ 10 | stream::{self, once}, 11 | StreamExt, 12 | }; 13 | use http::{ 14 | header::{ACCEPT, LOCATION, REFERER}, 15 | request::Parts, 16 | HeaderValue, StatusCode, Uri, 17 | }; 18 | use hydration_context::SsrSharedContext; 19 | use leptos::{ 20 | prelude::{provide_context, Owner, ScopedFuture}, 21 | IntoView, 22 | }; 23 | use leptos_integration_utils::{ExtendResponse, PinnedStream}; 24 | use leptos_meta::ServerMetaContext; 25 | use leptos_router::{ 26 | components::provide_server_redirect, location::RequestUrl, ExpandOptionals, 27 | PathSegment, RouteList, RouteListing, SsrMode, 28 | }; 29 | use mime_guess::MimeGuess; 30 | use routefinder::Router; 31 | use server_fn::{ 32 | codec::Encoding, http_export::Request, middleware::Service, 33 | response::generic::Body as ServerFnBody, ServerFn, ServerFnTraitObj, 34 | }; 35 | use std::sync::Arc; 36 | use thiserror::Error; 37 | use wasi::http::types::{ 38 | IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam, 39 | }; 40 | 41 | /// Handle routing, static file serving and response tx using the low-level 42 | /// `wasi:http` APIs. 43 | /// 44 | /// ## Performance Considerations 45 | /// 46 | /// This handler is optimised for the special case of WASI Components being spawned 47 | /// on a per-request basis. That is, the lifetime of the component is bound to the 48 | /// one of the request, so we don't do any fancy pre-setup: it means 49 | /// **your Server-Side will always be cold-started**. 50 | /// 51 | /// While it could have a bad impact on the performance of your app, please, know 52 | /// that there is a *shotcut* mechanism implemented that allows the [`Handler`] 53 | /// to shortcut the whole HTTP Rendering and Reactivity logic to directly jump to 54 | /// writting the response in those case: 55 | /// 56 | /// * The user request a static-file, then, calling [`Handler::static_files_handler`] 57 | /// will *shortcut* the handler and all future calls are ignored to reach 58 | /// [`Handler::handle_with_context`] *almost* instantly. 59 | /// * The user reach a server function, then, calling [`Handler::with_server_fn`] 60 | /// will check if the request's path matches the one from the passed server functions, 61 | /// if so, *shortcut* the handler. 62 | /// 63 | /// This implementation ensures that, even though your component is cold-started 64 | /// on each request, the performance are good. Please, note that this approach is 65 | /// directly enabled by the fact WASI Components have under-millisecond start-up 66 | /// times! It wouldn't be practical to do that with traditional container-based solutions. 67 | /// 68 | /// ## Limitations 69 | /// 70 | /// [`SsrMode::Static`] is not implemented yet, having one in your `` 71 | /// will cause [`Handler::handle_with_context`] to panic! 72 | pub struct Handler { 73 | req: Request, 74 | res_out: ResponseOutparam, 75 | 76 | // *shortcut* if any is set 77 | server_fn: 78 | Option, http::Response>>, 79 | preset_res: Option, 80 | should_404: bool, 81 | 82 | // built using the user-defined app_fn 83 | ssr_router: Router, 84 | } 85 | 86 | impl Handler { 87 | /// Wraps the WASI resources to handle the request. 88 | /// Could fail if the [`IncomingRequest`] cannot be converted to 89 | /// a [`http:Request`]. 90 | pub fn build( 91 | req: IncomingRequest, 92 | res_out: ResponseOutparam, 93 | ) -> Result { 94 | Ok(Self { 95 | req: crate::request::Request(req).try_into()?, 96 | res_out, 97 | server_fn: None, 98 | preset_res: None, 99 | ssr_router: Router::new(), 100 | should_404: false, 101 | }) 102 | } 103 | 104 | // Test whether we are ready to send a response to shortcut some 105 | // code and provide a fast-path. 106 | #[inline] 107 | const fn shortcut(&self) -> bool { 108 | self.server_fn.is_some() || self.preset_res.is_some() || self.should_404 109 | } 110 | 111 | /// Tests if the request path matches the bound server function 112 | /// and *shortcut* the [`Handler`] to quickly reach 113 | /// the call to [`Handler::handle_with_context`]. 114 | pub fn with_server_fn(mut self) -> Self 115 | where 116 | T: ServerFn< 117 | ServerRequest = Request, 118 | ServerResponse = http::Response, 119 | > + 'static, 120 | { 121 | if self.shortcut() { 122 | return self; 123 | } 124 | 125 | if self.req.method() == T::InputEncoding::METHOD 126 | && self.req.uri().path() == T::PATH 127 | { 128 | self.server_fn = Some(ServerFnTraitObj::new( 129 | T::PATH, 130 | T::InputEncoding::METHOD, 131 | |request| Box::pin(T::run_on_server(request)), 132 | T::middlewares, 133 | )); 134 | } 135 | 136 | self 137 | } 138 | 139 | /// If the request is prefixed with `prefix` [`Uri`], then 140 | /// the handler will call the passed `handler` with the Uri trimmed of 141 | /// the prefix. If the closure returns 142 | /// None, the response will be 404, otherwise, the returned [`Body`] 143 | /// will be served as-if. 144 | /// 145 | /// This function, when matching, *shortcut* the [`Handler`] to quickly reach 146 | /// the call to [`Handler::handle_with_context`]. 147 | pub fn static_files_handler( 148 | mut self, 149 | prefix: T, 150 | handler: impl Fn(String) -> Option + 'static + Send + Clone, 151 | ) -> Self 152 | where 153 | T: TryInto, 154 | >::Error: std::error::Error, 155 | { 156 | if self.shortcut() { 157 | return self; 158 | } 159 | 160 | if let Some(trimmed_url) = self.req.uri().path().strip_prefix( 161 | prefix.try_into().expect("you passed an invalid Uri").path(), 162 | ) { 163 | match handler(trimmed_url.to_string()) { 164 | None => self.should_404 = true, 165 | Some(body) => { 166 | let mut res = http::Response::new(body); 167 | let mime = MimeGuess::from_path(trimmed_url); 168 | 169 | res.headers_mut().insert( 170 | http::header::CONTENT_TYPE, 171 | HeaderValue::from_str( 172 | mime.first_or_octet_stream().as_ref(), 173 | ) 174 | .expect("internal error: could not parse MIME type"), 175 | ); 176 | 177 | self.preset_res = Some(Response(res)); 178 | } 179 | } 180 | } 181 | 182 | self 183 | } 184 | 185 | /// This mocks a request to the `app_fn` component to extract your 186 | /// ``'s ``. 187 | pub fn generate_routes( 188 | self, 189 | app_fn: impl Fn() -> IV + 'static + Send + Clone, 190 | ) -> Self 191 | where 192 | IV: IntoView + 'static, 193 | { 194 | self.generate_routes_with_exclusions_and_context(app_fn, None, || {}) 195 | } 196 | 197 | /// This mocks a request to the `app_fn` component to extract your 198 | /// ``'s ``. 199 | /// 200 | /// You can pass an `additional_context` to [`provide_context`] to the 201 | /// application. 202 | pub fn generate_routes_with_context( 203 | self, 204 | app_fn: impl Fn() -> IV + 'static + Send + Clone, 205 | additional_context: impl Fn() + 'static + Send + Clone, 206 | ) -> Self 207 | where 208 | IV: IntoView + 'static, 209 | { 210 | self.generate_routes_with_exclusions_and_context( 211 | app_fn, 212 | None, 213 | additional_context, 214 | ) 215 | } 216 | 217 | /// This mocks a request to the `app_fn` component to extract your 218 | /// ``'s ``. 219 | /// 220 | /// You can pass an `additional_context` to [`provide_context`] to the 221 | /// application. 222 | /// 223 | /// You can pass a list of `excluded_routes` to avoid generating them. 224 | pub fn generate_routes_with_exclusions_and_context( 225 | mut self, 226 | app_fn: impl Fn() -> IV + 'static + Send + Clone, 227 | excluded_routes: Option>, 228 | additional_context: impl Fn() + 'static + Send + Clone, 229 | ) -> Self 230 | where 231 | IV: IntoView + 'static, 232 | { 233 | // If we matched a server function, we do not need to go through 234 | // all of that. 235 | if self.shortcut() { 236 | return self; 237 | } 238 | 239 | if !self.ssr_router.is_empty() { 240 | panic!("generate_routes was called twice"); 241 | } 242 | 243 | let owner = Owner::new_root(Some(Arc::new(SsrSharedContext::new()))); 244 | let routes = owner 245 | .with(|| { 246 | // as we are generating the app to extract 247 | // the , we want to mock the root path. 248 | provide_context(RequestUrl::new("")); 249 | let (mock_meta, _) = ServerMetaContext::new(); 250 | let (mock_parts, _) = Request::new("").into_parts(); 251 | provide_context(mock_meta); 252 | provide_context(mock_parts); 253 | provide_context(ResponseOptions::default()); 254 | additional_context(); 255 | RouteList::generate(&app_fn) 256 | }) 257 | .unwrap_or_default() 258 | .into_inner() 259 | .into_iter() 260 | .flat_map(IntoRouteListing::into_route_listing) 261 | .filter(|route| { 262 | excluded_routes.as_ref().map_or(true, |excluded_routes| { 263 | !excluded_routes.iter().any(|ex_path| *ex_path == route.0) 264 | }) 265 | }); 266 | 267 | for (path, route_listing) in routes { 268 | self.ssr_router 269 | .add(path, route_listing) 270 | .expect("internal error: impossible to parse a RouteListing"); 271 | } 272 | 273 | self 274 | } 275 | 276 | /// Consumes the [`Handler`] to actually perform all the request handling 277 | /// logic. 278 | /// 279 | /// You can pass an `additional_context` to [`provide_context`] to the 280 | /// application. 281 | pub async fn handle_with_context( 282 | self, 283 | app: impl Fn() -> IV + 'static + Send + Clone, 284 | additional_context: impl Fn() + 'static + Clone + Send, 285 | ) -> Result<(), HandlerError> 286 | where 287 | IV: IntoView + 'static, 288 | { 289 | let path = self.req.uri().path().to_string(); 290 | let best_match = self.ssr_router.best_match(&path); 291 | let (parts, body) = self.req.into_parts(); 292 | let context_parts = parts.clone(); 293 | let req = Request::from_parts(parts, body); 294 | 295 | let owner = Owner::new(); 296 | let response = owner 297 | .with(|| { 298 | ScopedFuture::new(async move { 299 | let res_opts = ResponseOptions::default(); 300 | let response: Option = if self.should_404 { 301 | None 302 | } else if self.preset_res.is_some() { 303 | self.preset_res 304 | } else if let Some(mut sfn) = self.server_fn { 305 | provide_contexts(additional_context, context_parts, res_opts.clone()); 306 | 307 | // store Accepts and Referer in case we need them for redirect (below) 308 | let accepts_html = req 309 | .headers() 310 | .get(ACCEPT) 311 | .and_then(|v| v.to_str().ok()) 312 | .map(|v| v.contains("text/html")) 313 | .unwrap_or(false); 314 | let referrer = req.headers().get(REFERER).cloned(); 315 | 316 | let mut res = sfn.run(req).await; 317 | 318 | // if it accepts text/html (i.e., is a plain form post) and doesn't already have a 319 | // Location set, then redirect to to Referer 320 | if accepts_html { 321 | if let Some(referrer) = referrer { 322 | let has_location = res.headers().get(LOCATION).is_some(); 323 | if !has_location { 324 | *res.status_mut() = StatusCode::FOUND; 325 | res.headers_mut().insert(LOCATION, referrer); 326 | } 327 | } 328 | } 329 | 330 | Some(res.into()) 331 | } else if let Some(best_match) = best_match { 332 | let listing = best_match.handler(); 333 | let (meta_context, meta_output) = ServerMetaContext::new(); 334 | 335 | let add_ctx = additional_context.clone(); 336 | let additional_context = { 337 | let res_opts = res_opts.clone(); 338 | let meta_ctx = meta_context.clone(); 339 | move || { 340 | provide_contexts(add_ctx, context_parts, res_opts); 341 | provide_context(meta_ctx); 342 | } 343 | }; 344 | 345 | Some( 346 | Response::from_app( 347 | app, 348 | meta_output, 349 | additional_context, 350 | res_opts.clone(), 351 | match listing.mode() { 352 | SsrMode::Async => |app, chunks| { 353 | Box::pin(async move { 354 | let app = if cfg!(feature = "islands-router") { 355 | app.to_html_stream_in_order_branching() 356 | } else { 357 | app.to_html_stream_in_order() 358 | }; 359 | let app = app.collect::().await; 360 | let chunks = chunks(); 361 | Box::pin(once(async move { app }).chain(chunks)) 362 | as PinnedStream 363 | }) 364 | }, 365 | SsrMode::InOrder => |app, chunks| { 366 | Box::pin(async move { 367 | let app = if cfg!(feature = "islands-router") { 368 | app.to_html_stream_in_order_branching() 369 | } else { 370 | app.to_html_stream_in_order() 371 | }; 372 | Box::pin(app.chain(chunks())) as PinnedStream 373 | }) 374 | }, 375 | SsrMode::PartiallyBlocked | SsrMode::OutOfOrder => { 376 | |app, chunks| { 377 | Box::pin(async move { 378 | let app = if cfg!(feature = "islands-router") { 379 | app.to_html_stream_out_of_order_branching() 380 | } else { 381 | app.to_html_stream_out_of_order() 382 | }; 383 | Box::pin(app.chain(chunks())) 384 | as PinnedStream 385 | }) 386 | } 387 | } 388 | SsrMode::Static(_) => { 389 | panic!("SsrMode::Static routes are not supported yet!") 390 | } 391 | }, 392 | ) 393 | .await, 394 | ) 395 | } else { 396 | None 397 | }; 398 | 399 | response.map(|mut req| { 400 | req.extend_response(&res_opts); 401 | req 402 | }) 403 | }) 404 | }) 405 | .await; 406 | 407 | let response = response.unwrap_or_else(|| { 408 | let body = Bytes::from("404 not found"); 409 | let mut res = http::Response::new(Body::Sync(body)); 410 | *res.status_mut() = http::StatusCode::NOT_FOUND; 411 | Response(res) 412 | }); 413 | 414 | let headers = response.headers()?; 415 | let wasi_res = OutgoingResponse::new(headers); 416 | 417 | wasi_res 418 | .set_status_code(response.0.status().as_u16()) 419 | .expect("invalid http status code was returned"); 420 | let body = wasi_res.body().expect("unable to take response body"); 421 | ResponseOutparam::set(self.res_out, Ok(wasi_res)); 422 | 423 | let output_stream = body 424 | .write() 425 | .expect("unable to open writable stream on body"); 426 | let mut input_stream = match response.0.into_body() { 427 | Body::Sync(buf) => Box::pin(stream::once(async { Ok(buf) })), 428 | Body::Async(stream) => stream, 429 | }; 430 | 431 | while let Some(buf) = input_stream.next().await { 432 | let buf = buf.map_err(HandlerError::ResponseStream)?; 433 | let chunks = buf.chunks(CHUNK_BYTE_SIZE); 434 | for chunk in chunks { 435 | output_stream 436 | .blocking_write_and_flush(chunk) 437 | .map_err(HandlerError::from)?; 438 | } 439 | } 440 | 441 | drop(output_stream); 442 | OutgoingBody::finish(body, None) 443 | .map_err(HandlerError::WasiResponseBody)?; 444 | 445 | Ok(()) 446 | } 447 | } 448 | 449 | fn provide_contexts( 450 | additional_context: impl Fn() + 'static + Clone + Send, 451 | context_parts: Parts, 452 | res_opts: ResponseOptions, 453 | ) { 454 | provide_context(RequestUrl::new(context_parts.uri.path())); 455 | provide_context(context_parts); 456 | provide_context(res_opts); 457 | additional_context(); 458 | provide_server_redirect(redirect); 459 | leptos::nonce::provide_nonce(); 460 | } 461 | 462 | trait IntoRouteListing: Sized { 463 | fn into_route_listing(self) -> Vec<(String, RouteListing)>; 464 | } 465 | 466 | impl IntoRouteListing for RouteListing { 467 | fn into_route_listing(self) -> Vec<(String, RouteListing)> { 468 | self.path() 469 | .to_vec() 470 | .expand_optionals() 471 | .into_iter() 472 | .map(|path| { 473 | let path = path.to_rf_str_representation(); 474 | let path = if path.is_empty() { 475 | "/".to_string() 476 | } else { 477 | path 478 | }; 479 | (path, self.clone()) 480 | }) 481 | .collect() 482 | } 483 | } 484 | 485 | trait RouterPathRepresentation { 486 | fn to_rf_str_representation(&self) -> String; 487 | } 488 | 489 | impl RouterPathRepresentation for Vec { 490 | fn to_rf_str_representation(&self) -> String { 491 | let mut path = String::new(); 492 | for segment in self.iter() { 493 | // TODO trailing slash handling 494 | let raw = segment.as_raw_str(); 495 | if !raw.is_empty() && !raw.starts_with('/') { 496 | path.push('/'); 497 | } 498 | match segment { 499 | PathSegment::Static(s) => path.push_str(s), 500 | PathSegment::Param(s) => { 501 | path.push(':'); 502 | path.push_str(s); 503 | } 504 | PathSegment::Splat(_) => { 505 | path.push('*'); 506 | } 507 | PathSegment::Unit => {} 508 | PathSegment::OptionalParam(_) => { 509 | eprintln!("to_rf_str_representation should only be called on expanded paths, which do not have OptionalParam any longer"); 510 | Default::default() 511 | } 512 | } 513 | } 514 | path 515 | } 516 | } 517 | 518 | #[derive(Error, Debug)] 519 | pub enum HandlerError { 520 | #[error("error handling request")] 521 | Request(#[from] crate::request::RequestError), 522 | 523 | #[error("error handling response")] 524 | Response(#[from] crate::response::ResponseError), 525 | 526 | #[error("response stream emitted an error")] 527 | ResponseStream(throw_error::Error), 528 | 529 | #[error("wasi stream failure")] 530 | WasiStream(#[from] wasi::io::streams::StreamError), 531 | 532 | #[error("failed to finish response body")] 533 | WasiResponseBody(wasi::http::types::ErrorCode), 534 | } 535 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A first-party support of the `wasm32-wasip1` target for the **Server-Side** 2 | //! of Leptos using the [`wasi:http`][wasi-http] proposal. 3 | //! 4 | //! [wasi-http]: https://github.com/WebAssembly/wasi-http 5 | //! 6 | //! # `Handler` 7 | //! 8 | //! The [`prelude::Handler`] is the main abstraction you will use. 9 | //! 10 | //! It expects being run in the context of a Future Executor `Task`, 11 | //! since WASI is, at the moment, a single-threaded environment, 12 | //! we provide a simple abstraction in the form of [`leptos::spawn::Executor`] 13 | //! that you can leverage to use this crate. 14 | //! 15 | //! ``` 16 | //! use leptos::task::Executor; 17 | //! use leptos_wasi::prelude::WasiExecutor; 18 | //! use wasi::exports::http::incoming_handler::*; 19 | //! 20 | //! struct LeptosServer; 21 | //! 22 | //! impl Guest for LeptosServer { 23 | //! fn handle(request: IncomingRequest, response_out: ResponseOutparam) { 24 | //! // Initiate a single-threaded [`Future`] Executor so we can run the 25 | //! // rendering system and take advantage of bodies streaming. 26 | //! let executor = 27 | //! WasiExecutor::new(leptos_wasi::executor::Mode::Stalled); 28 | //! Executor::init_local_custom_executor(executor.clone()) 29 | //! .expect("cannot init future executor"); 30 | //! executor.run_until(async { 31 | //! //handle_request(request, response_out).await; 32 | //! }) 33 | //! } 34 | //! } 35 | //! ``` 36 | //! 37 | //! # WASI Bindings 38 | //! 39 | //! We are using the bindings provided by the `wasi` crate. 40 | 41 | pub mod executor; 42 | pub mod handler; 43 | pub mod request; 44 | pub mod response; 45 | pub mod utils; 46 | 47 | #[allow(clippy::pub_use)] 48 | pub mod prelude { 49 | pub use crate::{ 50 | executor::Executor as WasiExecutor, handler::Handler, response::Body, 51 | utils::redirect, 52 | }; 53 | pub use http::StatusCode; 54 | pub use wasi::exports::wasi::http::incoming_handler::{ 55 | IncomingRequest, ResponseOutparam, 56 | }; 57 | } 58 | 59 | /// When working with streams, this crate will try to chunk bytes with 60 | /// this size. 61 | const CHUNK_BYTE_SIZE: usize = 64; 62 | -------------------------------------------------------------------------------- /src/request.rs: -------------------------------------------------------------------------------- 1 | use crate::CHUNK_BYTE_SIZE; 2 | use bytes::Bytes; 3 | use http::{uri::Parts, Uri}; 4 | use thiserror::Error; 5 | use wasi::{ 6 | http::types::{IncomingBody, IncomingRequest, Method, Scheme}, 7 | io::streams::StreamError, 8 | }; 9 | 10 | pub struct Request(pub IncomingRequest); 11 | 12 | impl TryFrom for http::Request { 13 | type Error = RequestError; 14 | 15 | fn try_from(req: Request) -> Result { 16 | let mut builder = http::Request::builder(); 17 | let req = req.0; 18 | let req_method = method_wasi_to_http(req.method())?; 19 | let headers = req.headers(); 20 | 21 | for (header_name, header_value) in headers.entries() { 22 | builder = builder.header(header_name, header_value); 23 | } 24 | 25 | drop(headers); 26 | 27 | // NB(raskyld): consume could fail if, for some reason the caller 28 | // manage to recreate an IncomingRequest backed by the same underlying 29 | // resource handle (need to dig more to see if that's possible) 30 | let incoming_body = req.consume().expect("could not consume body"); 31 | 32 | let body_stream = incoming_body 33 | .stream() 34 | .expect("could not create a stream from body"); 35 | 36 | let mut body_bytes = Vec::::with_capacity(CHUNK_BYTE_SIZE); 37 | 38 | loop { 39 | match body_stream.blocking_read(CHUNK_BYTE_SIZE as u64) { 40 | Err(StreamError::Closed) => break, 41 | Err(StreamError::LastOperationFailed(err)) => { 42 | return Err(StreamError::LastOperationFailed(err).into()) 43 | } 44 | Ok(data) => { 45 | body_bytes.extend(data); 46 | } 47 | } 48 | } 49 | 50 | let mut uri_parts = Parts::default(); 51 | 52 | uri_parts.scheme = req.scheme().map(scheme_wasi_to_http).transpose()?; 53 | uri_parts.authority = req 54 | .authority() 55 | .map(|aut| { 56 | http::uri::Authority::from_maybe_shared(aut.into_bytes()) 57 | }) 58 | .transpose() 59 | .map_err(http::Error::from)?; 60 | uri_parts.path_and_query = req 61 | .path_with_query() 62 | .map(|paq| { 63 | http::uri::PathAndQuery::from_maybe_shared(paq.into_bytes()) 64 | }) 65 | .transpose() 66 | .map_err(http::Error::from)?; 67 | 68 | drop(body_stream); 69 | IncomingBody::finish(incoming_body); 70 | builder 71 | .method(req_method) 72 | .uri(Uri::from_parts(uri_parts).map_err(http::Error::from)?) 73 | .body(Bytes::from(body_bytes)) 74 | .map_err(RequestError::from) 75 | } 76 | } 77 | 78 | #[derive(Error, Debug)] 79 | #[non_exhaustive] 80 | pub enum RequestError { 81 | #[error("failed to convert wasi bindings to http types")] 82 | Http(#[from] http::Error), 83 | 84 | #[error("error while processing wasi:http body stream")] 85 | WasiIo(#[from] wasi::io::streams::StreamError), 86 | } 87 | 88 | pub fn method_wasi_to_http(value: Method) -> Result { 89 | match value { 90 | Method::Connect => Ok(http::Method::CONNECT), 91 | Method::Delete => Ok(http::Method::DELETE), 92 | Method::Get => Ok(http::Method::GET), 93 | Method::Head => Ok(http::Method::HEAD), 94 | Method::Options => Ok(http::Method::OPTIONS), 95 | Method::Patch => Ok(http::Method::PATCH), 96 | Method::Post => Ok(http::Method::POST), 97 | Method::Put => Ok(http::Method::PUT), 98 | Method::Trace => Ok(http::Method::TRACE), 99 | Method::Other(mtd) => { 100 | http::Method::from_bytes(mtd.as_bytes()).map_err(http::Error::from) 101 | } 102 | } 103 | } 104 | 105 | pub fn scheme_wasi_to_http( 106 | value: Scheme, 107 | ) -> Result { 108 | match value { 109 | Scheme::Http => Ok(http::uri::Scheme::HTTP), 110 | Scheme::Https => Ok(http::uri::Scheme::HTTPS), 111 | Scheme::Other(oth) => http::uri::Scheme::try_from(oth.as_bytes()) 112 | .map_err(http::Error::from), 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/response.rs: -------------------------------------------------------------------------------- 1 | use bytes::Bytes; 2 | use futures::{Stream, StreamExt}; 3 | use http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; 4 | use leptos_integration_utils::ExtendResponse; 5 | use parking_lot::RwLock; 6 | use server_fn::response::generic::Body as ServerFnBody; 7 | use std::{pin::Pin, sync::Arc}; 8 | use thiserror::Error; 9 | use wasi::http::types::{HeaderError, Headers}; 10 | 11 | /// This crate uses platform-agnostic [`http::Response`] 12 | /// with a custom [`Body`] and convert them under the hood to 13 | /// WASI native types. 14 | /// 15 | /// It supports both [`Body::Sync`] and [`Body::Async`], 16 | /// allowing you to choose between synchronous response 17 | /// (i.e. sending the whole response) and asynchronous response 18 | /// (i.e. streaming the response). 19 | pub struct Response(pub http::Response); 20 | 21 | impl Response { 22 | pub fn headers(&self) -> Result { 23 | let headers = Headers::new(); 24 | for (name, value) in self.0.headers() { 25 | headers.append(&name.to_string(), &Vec::from(value.as_bytes()))?; 26 | } 27 | Ok(headers) 28 | } 29 | } 30 | 31 | impl From> for Response 32 | where 33 | T: Into, 34 | { 35 | fn from(value: http::Response) -> Self { 36 | Self(value.map(Into::into)) 37 | } 38 | } 39 | 40 | pub enum Body { 41 | /// The response body will be written synchronously. 42 | Sync(Bytes), 43 | 44 | /// The response body will be written asynchronously, 45 | /// this execution model is also known as 46 | /// "streaming". 47 | Async( 48 | Pin< 49 | Box< 50 | dyn Stream> 51 | + Send 52 | + 'static, 53 | >, 54 | >, 55 | ), 56 | } 57 | 58 | impl From for Body { 59 | fn from(value: ServerFnBody) -> Self { 60 | match value { 61 | ServerFnBody::Sync(data) => Self::Sync(data), 62 | ServerFnBody::Async(stream) => Self::Async(stream), 63 | } 64 | } 65 | } 66 | 67 | /// This struct lets you define headers and override the status of the Response from an Element or a Server Function 68 | /// Typically contained inside of a ResponseOptions. Setting this is useful for cookies and custom responses. 69 | #[derive(Debug, Clone, Default)] 70 | pub struct ResponseParts { 71 | pub headers: HeaderMap, 72 | pub status: Option, 73 | } 74 | 75 | /// Allows you to override details of the HTTP response like the status code and add Headers/Cookies. 76 | #[derive(Debug, Clone, Default)] 77 | pub struct ResponseOptions(Arc>); 78 | 79 | impl ResponseOptions { 80 | /// A simpler way to overwrite the contents of `ResponseOptions` with a new `ResponseParts`. 81 | #[inline] 82 | pub fn overwrite(&self, parts: ResponseParts) { 83 | *self.0.write() = parts 84 | } 85 | /// Set the status of the returned Response. 86 | #[inline] 87 | pub fn set_status(&self, status: StatusCode) { 88 | self.0.write().status = Some(status); 89 | } 90 | /// Insert a header, overwriting any previous value with the same key. 91 | #[inline] 92 | pub fn insert_header(&self, key: HeaderName, value: HeaderValue) { 93 | self.0.write().headers.insert(key, value); 94 | } 95 | /// Append a header, leaving any header with the same key intact. 96 | #[inline] 97 | pub fn append_header(&self, key: HeaderName, value: HeaderValue) { 98 | self.0.write().headers.append(key, value); 99 | } 100 | } 101 | 102 | impl ExtendResponse for Response { 103 | type ResponseOptions = ResponseOptions; 104 | 105 | fn from_stream( 106 | stream: impl Stream + Send + 'static, 107 | ) -> Self { 108 | let stream = stream.map(|data| { 109 | Result::::Ok(Bytes::from(data)) 110 | }); 111 | 112 | Self(http::Response::new(Body::Async(Box::pin(stream)))) 113 | } 114 | 115 | fn extend_response(&mut self, opt: &Self::ResponseOptions) { 116 | let mut opt = opt.0.write(); 117 | if let Some(status_code) = opt.status { 118 | *self.0.status_mut() = status_code; 119 | } 120 | self.0 121 | .headers_mut() 122 | .extend(std::mem::take(&mut opt.headers)); 123 | } 124 | 125 | fn set_default_content_type(&mut self, content_type: &str) { 126 | let headers = self.0.headers_mut(); 127 | if !headers.contains_key(http::header::CONTENT_TYPE) { 128 | headers.insert( 129 | http::header::CONTENT_TYPE, 130 | HeaderValue::from_str(content_type).unwrap(), 131 | ); 132 | } 133 | } 134 | } 135 | 136 | #[derive(Error, Debug)] 137 | #[non_exhaustive] 138 | pub enum ResponseError { 139 | #[error("failed to parse http::Response's headers")] 140 | WasiHeaders(#[from] HeaderError), 141 | } 142 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::response::ResponseOptions; 2 | use http::{header, request::Parts, HeaderName, HeaderValue, StatusCode}; 3 | use leptos::prelude::use_context; 4 | use server_fn::redirect::REDIRECT_HEADER; 5 | 6 | /// Allow to return an HTTP redirection from components. 7 | pub fn redirect(path: &str) { 8 | if let (Some(req), Some(res)) = 9 | (use_context::(), use_context::()) 10 | { 11 | // insert the Location header in any case 12 | res.insert_header( 13 | header::LOCATION, 14 | header::HeaderValue::from_str(path) 15 | .expect("Failed to create HeaderValue"), 16 | ); 17 | 18 | let accepts_html = req 19 | .headers 20 | .get(header::ACCEPT) 21 | .and_then(|v| v.to_str().ok()) 22 | .map(|v| v.contains("text/html")) 23 | .unwrap_or(false); 24 | if accepts_html { 25 | // if the request accepts text/html, it's a plain form request and needs 26 | // to have the 302 code set 27 | res.set_status(StatusCode::FOUND); 28 | } else { 29 | // otherwise, we sent it from the server fn client and actually don't want 30 | // to set a real redirect, as this will break the ability to return data 31 | // instead, set the REDIRECT_HEADER to indicate that the client should redirect 32 | res.insert_header( 33 | HeaderName::from_static(REDIRECT_HEADER), 34 | HeaderValue::from_str("").unwrap(), 35 | ); 36 | } 37 | } else { 38 | eprintln!( 39 | "Couldn't retrieve either Parts or ResponseOptions while trying \ 40 | to redirect()." 41 | ); 42 | } 43 | } 44 | --------------------------------------------------------------------------------