├── .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 |
--------------------------------------------------------------------------------