mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-27 15:44:42 -05:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d50ca32cd | ||
|
|
e576d93f83 | ||
|
|
e71779b8a6 | ||
|
|
0301c7f1cf | ||
|
|
46e6e7629c | ||
|
|
a985ae5660 | ||
|
|
efe29fd057 | ||
|
|
05b5b25c0e | ||
|
|
46f6deddbf | ||
|
|
e9c4b490e5 | ||
|
|
f7d0eea04d | ||
|
|
bf246a62e7 | ||
|
|
b8acfd758d | ||
|
|
dc8fd37461 | ||
|
|
cc2b310e4c | ||
|
|
884348d7f8 | ||
|
|
dafd6e51c5 | ||
|
|
322041917d | ||
|
|
a2eaf9b3ee | ||
|
|
4032bfc210 | ||
|
|
4ff08f042b | ||
|
|
ce4b0ecbe1 | ||
|
|
6c31d09eb2 | ||
|
|
59ad6a4725 | ||
|
|
884dacbc6c | ||
|
|
9c572f7617 | ||
|
|
487dba90d8 | ||
|
|
20f24d2f3a | ||
|
|
20cbc240ee | ||
|
|
f2f52b2533 | ||
|
|
46d6e3f78c | ||
|
|
586f524015 | ||
|
|
79781ec20c | ||
|
|
91f6d9a404 | ||
|
|
76a74ecde2 | ||
|
|
0071a48b8a | ||
|
|
8d42e91eb8 | ||
|
|
00a796d204 | ||
|
|
bde585dc3e | ||
|
|
0a534bd7fd | ||
|
|
50d8eae694 | ||
|
|
e732a4952b | ||
|
|
8a99623fd6 | ||
|
|
7d6c4930e4 | ||
|
|
81d6689cc0 | ||
|
|
989b5b93c3 | ||
|
|
ca510f72c1 | ||
|
|
6dd3be75d1 | ||
|
|
51e11e756a | ||
|
|
1dbcfe2861 | ||
|
|
db3f46c501 | ||
|
|
1cba54d47e | ||
|
|
d1ae3b49cc | ||
|
|
6bab4ad966 | ||
|
|
d4648da5c6 | ||
|
|
cf7deaaea3 | ||
|
|
d0cacecfc6 | ||
|
|
ce2c3ec97c | ||
|
|
b9f05f94ce | ||
|
|
fe7aacb0c8 | ||
|
|
3fd3e73a10 | ||
|
|
7dca740e47 | ||
|
|
73420affed | ||
|
|
7c25f59a68 | ||
|
|
c24874d9c8 | ||
|
|
4759dfcb60 | ||
|
|
ca9419b53f |
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -30,6 +30,9 @@ jobs:
|
||||
override: true
|
||||
components: rustfmt
|
||||
|
||||
- name: Add wasm32-unknown-unknown
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
|
||||
- name: Setup cargo-make
|
||||
uses: davidB/rust-cargo-make@v1
|
||||
|
||||
@@ -43,4 +46,3 @@ jobs:
|
||||
|
||||
- name: Run tests with all features
|
||||
run: cargo make ci
|
||||
|
||||
|
||||
21
Cargo.toml
21
Cargo.toml
@@ -11,6 +11,8 @@ members = [
|
||||
# integrations
|
||||
"integrations/actix",
|
||||
"integrations/axum",
|
||||
"integrations/viz",
|
||||
"integrations/utils",
|
||||
|
||||
# libraries
|
||||
"meta",
|
||||
@@ -19,17 +21,18 @@ members = [
|
||||
exclude = ["benchmarks", "examples"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
leptos = { path = "./leptos", default-features = false, version = "0.1.3" }
|
||||
leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.1.3" }
|
||||
leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.1.3" }
|
||||
leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.1.3" }
|
||||
leptos_server = { path = "./leptos_server", default-features = false, version = "0.1.3" }
|
||||
leptos_config = { path = "./leptos_config", default-features = false, version = "0.1.3" }
|
||||
leptos_router = { path = "./router", version = "0.1.3" }
|
||||
leptos_meta = { path = "./meta", default-feature = false, version = "0.1.3" }
|
||||
leptos = { path = "./leptos", default-features = false, version = "0.2.0" }
|
||||
leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.0" }
|
||||
leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.0" }
|
||||
leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.0" }
|
||||
leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.0" }
|
||||
leptos_config = { path = "./leptos_config", default-features = false, version = "0.2.0" }
|
||||
leptos_router = { path = "./router", version = "0.2.0" }
|
||||
leptos_meta = { path = "./meta", default-feature = false, version = "0.2.0" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.2.0" }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -12,13 +12,17 @@ dependencies = ["build", "check-examples", "test"]
|
||||
|
||||
[tasks.build]
|
||||
clear = true
|
||||
dependencies = ["build-all"]
|
||||
dependencies = ["build-all", "build-wasm"]
|
||||
|
||||
[tasks.build-all]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.build-wasm]
|
||||
clear = true
|
||||
dependencies = [{ name = "build-wasm", path = "leptos" }]
|
||||
|
||||
[tasks.check-examples]
|
||||
clear = true
|
||||
dependencies = [
|
||||
@@ -36,6 +40,7 @@ dependencies = [
|
||||
{ name = "check", path = "examples/tailwind" },
|
||||
{ name = "check", path = "examples/todo_app_sqlite" },
|
||||
{ name = "check", path = "examples/todo_app_sqlite_axum" },
|
||||
{ name = "check", path = "examples/todo_app_sqlite_viz" },
|
||||
{ name = "check", path = "examples/todomvc" },
|
||||
]
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ rustup target add wasm32-unknown-unknown
|
||||
|
||||
If you’re on `stable`, note the following:
|
||||
|
||||
1. You need to enable the `"stable"` flag in `Cargo.toml`: `leptos = { version = "0.1.0-alpha", features = ["stable"] }`
|
||||
1. You need to enable the `"stable"` flag in `Cargo.toml`: `leptos = { version = "0.1.0", features = ["stable"] }`
|
||||
2. `nightly` enables the function call syntax for accessing and setting signals. If you’re using `stable`,
|
||||
you’ll just call `.get()`, `.set()`, or `.update()` manually. Check out the
|
||||
[`counters_stable` example](https://github.com/leptos-rs/leptos/blob/main/examples/counters_stable/src/main.rs)
|
||||
@@ -99,6 +99,10 @@ Open browser on [http://localhost:3000/](http://localhost:3000/)
|
||||
|
||||
## FAQs
|
||||
|
||||
### What’s up with the name?
|
||||
|
||||
*Leptos* (λεπτός) is an ancient Greek word meaning “thin, light, refine, fine-grained.” To me, a classicist and not a dog owner, it evokes the lightweight reactive system that powers the framework. I've since learned the same word is at the root of the medical term “leptospirosis,” a blood infection that affects humans and animals... My bad. No dogs were harmed in the creation of this framework.
|
||||
|
||||
### Is it production ready?
|
||||
|
||||
People usually mean one of three things by this question.
|
||||
|
||||
1
docs/book/.gitignore
vendored
Normal file
1
docs/book/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
book
|
||||
@@ -11,14 +11,14 @@
|
||||
- [Control Flow](./view/06_control_flow.md)
|
||||
- [Error Handling](./view/07_errors.md)
|
||||
- [Parent-Child Communication](./view/08_parent_child.md)
|
||||
- [Passing Children to Components]()
|
||||
- [Interlude: Reactivity and Functions]()
|
||||
- [Testing]()
|
||||
- [Interlude: Styling — CSS, Tailwind, Style.rs, and more]()
|
||||
- [Async]()
|
||||
- [Resource]()
|
||||
- [Suspense]()
|
||||
- [Passing Children to Components](./view/09_component_children.md)
|
||||
- [Interlude: Reactivity and Functions](./interlude_functions.md)
|
||||
- [Testing](./testing.md)
|
||||
- [Async](./async/README.md)
|
||||
- [Loading Data with Resources](./async/10_resources.md)
|
||||
- [Suspense](./async/11_suspense.md)
|
||||
- [Transition]()
|
||||
- [Interlude: Styling — CSS, Tailwind, Style.rs, and more]()
|
||||
- [State Management]()
|
||||
- [Interlude: Advanced Reactivity]()
|
||||
- [Router]()
|
||||
|
||||
53
docs/book/src/async/10_resources.md
Normal file
53
docs/book/src/async/10_resources.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Loading Data with Resources
|
||||
|
||||
A [Resource](https://docs.rs/leptos/latest/leptos/struct.Resource.html) is a reactive data structure that reflects the current state of an asynchronous task, allowing you to integrate asynchronous `Future`s into the synchronous reactive system. Rather than waiting for its data to load with `.await`, you transform the `Future` into a signal that returns `Some(T)` if it has resolved, and `None` if it’s still pending.
|
||||
|
||||
You do this by using the [`create_resource`](https://docs.rs/leptos/latest/leptos/fn.create_resource.html) function. This takes two arguments (other than the ubiquitous `cx`):
|
||||
|
||||
1. a source signal, which will generate a new `Future` whenever it changes
|
||||
2. a fetcher function, which takes the data from that signal and returns a `Future`
|
||||
|
||||
Here’s an example
|
||||
|
||||
```rust
|
||||
// our source signal: some synchronous, local state
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
// our resource
|
||||
let async_data = create_resource(cx,
|
||||
count,
|
||||
// every time `count` changes, this will run
|
||||
|value| async move {
|
||||
log!("loading data from API");
|
||||
load_data(value).await
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
To create a resource that simply runs once, you can pass a non-reactive, empty source signal:
|
||||
|
||||
```rust
|
||||
let once = create_resource(cx, || (), |_| async move { load_data().await });
|
||||
```
|
||||
|
||||
To access the value you can use `.read(cx)` or `.with(cx, |data| /* */)`. These work just like `.get()` and `.with()` on a signal—`read` clones the value and returns it, `with` applies a closure to it—but with two differences
|
||||
|
||||
1. For any `Resource<_, T>`, they always return `Option<T>`, not `T`: because it’s always possible that your resource is still loading.
|
||||
2. They take a `Scope` argument. You’ll see why in the next chapter, on `<Suspense/>`.
|
||||
|
||||
So, you can show the current state of a resource in your view:
|
||||
|
||||
```rust
|
||||
let once = create_resource(cx, || (), |_| async move { load_data().await });
|
||||
view! { cx,
|
||||
<h1>"My Data"</h1>
|
||||
{move || match once.read(cx) {
|
||||
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
|
||||
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
Resources also provide a `refetch()` method that allow you to manually reload the data (for example, in response to a button click) and a `loading()` method that returns a `ReadSignal<bool>` indicating whether the resource is currently loading or not.
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/10-async-resources-4z0qt3?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D" width="100%" height="1000px"></iframe>
|
||||
72
docs/book/src/async/11_suspense.md
Normal file
72
docs/book/src/async/11_suspense.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# `<Suspense/>`
|
||||
|
||||
In the previous chapter, we showed how you can create a simple loading screen to show some fallback while a resource is loading.
|
||||
|
||||
```rust
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
let a = create_resource(cx, count, |count| async move { load_a(count).await });
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Data"</h1>
|
||||
{move || match once.read(cx) {
|
||||
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
|
||||
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
But what if we have two resources, and want to wait for both of them?
|
||||
|
||||
```rust
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
let (count2, set_count2) = create_signal(cx, 0);
|
||||
let a = create_resource(cx, count, |count| async move { load_a(count).await });
|
||||
let b = create_resource(cx, count2, |count| async move { load_b(count).await });
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Data"</h1>
|
||||
{move || match (a.read(cx), b.read(cx)) {
|
||||
_ => view! { cx, <p>"Loading..."</p> }.into_view(cx),
|
||||
(Some(a), Some(b)) => view! { cx,
|
||||
<ShowA a/>
|
||||
<ShowA b/>
|
||||
}.into_view(cx)
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
That’s not _so_ bad, but it’s kind of annoying. What if we could invert the flow of control?
|
||||
|
||||
The [`<Suspense/>`](https://docs.rs/leptos/latest/leptos/fn.Suspense.html) component lets us do exactly that. You give it a `fallback` prop and children, one or more of which usually involves reading from a resource. Reading from a resource “under” a `<Suspense/>` (i.e., in one of its children) registers that resource with the `<Suspense/>`. If it’s still waiting for resources to load, it shows the `fallback`. When they’ve all loaded, it shows the children.
|
||||
|
||||
```rust
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
let (count2, set_count2) = create_signal(cx, 0);
|
||||
let a = create_resource(cx, count, |count| async move { load_a(count).await });
|
||||
let b = create_resource(cx, count2, |count| async move { load_b(count).await });
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Data"</h1>
|
||||
<Suspense
|
||||
fallback=move || view! { cx, <p>"Loading..."</p> }
|
||||
>
|
||||
<h2>"My Data"</h2>
|
||||
<h3>"A"</h3>
|
||||
{move || {
|
||||
a.read(cx)
|
||||
.map(|a| view! { cx, <ShowA a/> })
|
||||
}}
|
||||
<h3>"B"</h3>
|
||||
{move || {
|
||||
b.read(cx)
|
||||
.map(|b| view! { cx, <ShowB b/> })
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
```
|
||||
|
||||
Every time one of the resources is reloading, the `"Loading..."` fallback will show again.
|
||||
|
||||
This inversion of the flow of control makes it easier to add or remove individual resources, as you don’t need to handle the matching yourself. It also unlocks some massive performance improvements during server-side rendering, which we’ll talk about during a later chapter.
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/10-async-resources-4z0qt3?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D" width="100%" height="1000px"></iframe>
|
||||
9
docs/book/src/async/12_transition.md
Normal file
9
docs/book/src/async/12_transition.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# `<Transition/>`
|
||||
|
||||
You’ll notice in the `<Suspense/>` example that if you keep reloading the data, it keeps flickering back to `"Loading..."`. Sometimes this is fine. For other times, there’s [`<Transition/>`](https://docs.rs/leptos/latest/leptos/fn.Suspense.html).
|
||||
|
||||
`<Transition/>` behaves exactly the same as `<Suspense/>`, but instead of falling back every time, it only shows the fallback the first time. On all subsequent loads, it continues showing the old data until the new data are ready. This can be really handy to prevent the flickering effect, and to allow users to continue interacting with your application.
|
||||
|
||||
This example shows how you can create a simple tabbed contact list with `<Transition/>`. When you select a new tab, it continues showing the current contact until the new data laods. This can be a much better user experience than constantly falling back to a loading message.
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/12-transition-sn38sd?selection=%5B%7B%22endColumn%22%3A15%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A15%2C%22startLineNumber%22%3A2%7D%5D&file=%2Fsrc%2Fmain.rs" width="100%" height="1000px"></iframe>
|
||||
9
docs/book/src/async/README.md
Normal file
9
docs/book/src/async/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Working with `async`
|
||||
|
||||
So far we’ve only been working with synchronous users interfaces: You provide some input,
|
||||
the app immediately process it and updates the interface. This is great, but is a tiny
|
||||
subset of what web applications do. In particular, most web apps have to deal with some kind
|
||||
of asynchronous data loading, usually loading something from an API.
|
||||
|
||||
Asynchronous data is notoriously hard to integrate with the synchronous parts of your code.
|
||||
In this chapter, we’ll see how Leptos helps smooth out that process for you.
|
||||
76
docs/book/src/interlude_functions.md
Normal file
76
docs/book/src/interlude_functions.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Interlude: Reactivity and Functions
|
||||
|
||||
One of our core contributors said to me recently: “I never used closures this often
|
||||
until I started using Leptos.” And it’s true. Closures are at the heart of any Leptos
|
||||
application. It sometimes looks a little silly:
|
||||
|
||||
```rust
|
||||
// a signal holds a value, and can be updated
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
// a derived signal is a function that accesses other signals
|
||||
let double_count = move || count() * 2;
|
||||
let count_is_odd = move || count() & 1 == 1;
|
||||
let text = move || if count_is_odd() {
|
||||
"odd"
|
||||
} else {
|
||||
"even"
|
||||
};
|
||||
|
||||
// an effect automatically tracks the signals it depends on
|
||||
// and re-runs when they change
|
||||
create_effect(cx, move |_| {
|
||||
log!("text = {}", text());
|
||||
});
|
||||
|
||||
view! { cx,
|
||||
<p>{move || text().to_uppercase()}</p>
|
||||
}
|
||||
```
|
||||
|
||||
Closures, closures everywhere!
|
||||
|
||||
But why?
|
||||
|
||||
## Functions and UI Frameworks
|
||||
|
||||
Functions are at the heart of every UI framework. And this makes perfect sense. Creating a user interface is basically divided into two phases:
|
||||
|
||||
1. initial rendering
|
||||
2. updates
|
||||
|
||||
In a web framework, the framework does some kind of initial rendering. Then it hands control back over to the browser. When certain events fire (like a mouse click) or asynchronous tasks finish (like an HTTP request finishing), the browser wakes the framework back up to update something. The framework runs some kind of code to update your user interface, and goes back asleep until the browser wakes it up again.
|
||||
|
||||
The key phrase here is “runs some kind of code.” The natural way to “run some kind of code” at an arbitrary point in time—in Rust or in any other programming language—is to call a function. And in fact every UI framework is based on rerunning some kind of function over and over:
|
||||
|
||||
1. virtual DOM (VDOM) frameworks like React, Yew, or Dioxus rerun a component or render function over and over, to generate a virtual DOM tree that can be reconciled with the previous result to patch the DOM
|
||||
2. compiled frameworks like Angular and Svelte divide your component templates into “create” and “update” functions, rerunning the update function when they detect a change to the component’s state
|
||||
3. in fine-grained reactive frameworks like SolidJS, Sycamore, or Leptos, _you_ define the functions that re-run
|
||||
|
||||
That’s what all our components are doing.
|
||||
|
||||
Take our typical `<SimpleCounter/>` example in its simplest form:
|
||||
|
||||
```rust
|
||||
#[component]
|
||||
pub fn SimpleCounter(cx: Scope) -> impl IntoView {
|
||||
let (value, set_value) = create_signal(cx, 0);
|
||||
|
||||
let increment = move |_| set_value.update(|value| *value += 1);
|
||||
|
||||
view! { cx,
|
||||
<button on:click=increment>
|
||||
{value}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `SimpleCounter` function itself runs once. The `value` signal is created once. The framework hands off the `increment` function to the browser as an event listener. When you click the button, the browser calls `increment`, which updates `value` via `set_value`. And that updates the single text node represented in our view by `{value}`.
|
||||
|
||||
Closures are key to reactivity. They provide the framework with the ability to re-run the smallest possible unit of your application in responsive to a change.
|
||||
|
||||
So remember two things:
|
||||
|
||||
1. Your component function is a setup function, not a render function: it only runs once.
|
||||
2. For values in your view template to be reactive, they must be functions: either signals (which implement the `Fn` traits) or closures.
|
||||
180
docs/book/src/testing.md
Normal file
180
docs/book/src/testing.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Testing Your Components
|
||||
|
||||
Testing user interfaces can be relatively tricky, but really important. This article
|
||||
will discuss a couple principles and approaches for testing a Leptos app.
|
||||
|
||||
## 1. Test business logic with ordinary Rust tests
|
||||
|
||||
In many cases, it makes sense to pull the logic out of your components and test
|
||||
it separately. For some simple components, there’s no particular logic to test, but
|
||||
for many it’s worth using a testable wrapping type and implementing the logic in
|
||||
ordinary Rust `impl` blocks.
|
||||
|
||||
For example, instead of embedding logic in a component directly like this:
|
||||
|
||||
```rust
|
||||
#[component]
|
||||
pub fn TodoApp(cx: Scope) -> impl IntoView {
|
||||
let (todos, set_todos) = create_signal(cx, vec![Todo { /* ... */ }]);
|
||||
// ⚠️ this is hard to test because it's embedded in the component
|
||||
let maximum = move || todos.with(|todos| {
|
||||
todos.iter().filter(|todo| todo.completed).sum()
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
You could pull that logic out into a separate data structure and test it:
|
||||
|
||||
```rust
|
||||
pub struct Todos(Vec<Todo>);
|
||||
|
||||
impl Todos {
|
||||
pub fn remaining(&self) -> usize {
|
||||
todos.iter().filter(|todo| todo.completed).sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_remaining {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TodoApp(cx: Scope) -> impl IntoView {
|
||||
let (todos, set_todos) = create_signal(cx, Todos(vec![Todo { /* ... */ }]));
|
||||
// ✅ this has a test associated with it
|
||||
let maximum = move || todos.with(Todos::remaining);
|
||||
}
|
||||
```
|
||||
|
||||
In general, the less of your logic is wrapped into your components themselves, the
|
||||
more idiomatic your code will feel and the easier it will be to test.
|
||||
|
||||
## 2. Test components with `wasm-bindgen-test`
|
||||
|
||||
[`wasm-bindgen-test`](https://crates.io/crates/wasm-bindgen-test) is a great utility
|
||||
for integrating or end-to-end testing WebAssembly apps in a headless browser.
|
||||
|
||||
To use this testing utility, you need to add `wasm-bindgen-test` to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.0"
|
||||
```
|
||||
|
||||
You should create tests in a separate `tests` directory. You can then run your tests in the browser of your choice:
|
||||
|
||||
```bash
|
||||
wasm-pack test --firefox
|
||||
```
|
||||
|
||||
> To see the full setup, check out the tests for the [`counter`](https://github.com/leptos-rs/leptos/tree/main/examples/counter) example.
|
||||
|
||||
### Writing Your Tests
|
||||
|
||||
Most tests will involve some combination of vanilla DOM manipulation and comparison to a `view`. For example, here’s a test [for the
|
||||
`counter` example](https://github.com/leptos-rs/leptos/blob/main/examples/counter/tests/mod.rs).
|
||||
|
||||
First, we set up the testing environment.
|
||||
|
||||
```rust
|
||||
use wasm_bindgen_test::*;
|
||||
use counter::*;
|
||||
use leptos::*;
|
||||
use web_sys::HtmlElement;
|
||||
|
||||
// tell the test runner to run tests in the browser
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
```
|
||||
|
||||
I’m going to create a simpler wrapper for each test case, and mount it there.
|
||||
This makes it easy to encapsulate the test results.
|
||||
|
||||
```rust
|
||||
// like marking a regular test with #[test]
|
||||
#[wasm_bindgen_test]
|
||||
fn clear() {
|
||||
let document = leptos::document();
|
||||
let test_wrapper = document.create_element("section").unwrap();
|
||||
document.body().unwrap().append_child(&test_wrapper);
|
||||
|
||||
// start by rendering our counter and mounting it to the DOM
|
||||
// note that we start at the initial value of 10
|
||||
mount_to(
|
||||
test_wrapper.clone().unchecked_into(),
|
||||
|cx| view! { cx, <SimpleCounter initial_value=10 step=1/> },
|
||||
);
|
||||
```
|
||||
|
||||
We’ll use some manual DOM operations to grab the `<div>` that wraps
|
||||
the whole component, as well as the `clear` button.
|
||||
|
||||
```rust
|
||||
// now we extract the buttons by iterating over the DOM
|
||||
// this would be easier if they had IDs
|
||||
let div = test_wrapper.query_selector("div").unwrap().unwrap();
|
||||
let clear = test_wrapper
|
||||
.query_selector("button")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlElement>();
|
||||
```
|
||||
|
||||
Now we can use ordinary DOM APIs to simulate user interaction.
|
||||
|
||||
```rust
|
||||
// now let's click the `clear` button
|
||||
clear.click();
|
||||
```
|
||||
|
||||
You can test individual DOM element attributes or text node values. Sometimes
|
||||
I like to test the whole view at once. We can do this by testing the element’s
|
||||
`outerHTML` against our expectations.
|
||||
|
||||
```rust
|
||||
assert_eq!(
|
||||
div.outer_html(),
|
||||
// here we spawn a mini reactive system to render the test case
|
||||
run_scope(create_runtime(), |cx| {
|
||||
// it's as if we're creating it with a value of 0, right?
|
||||
let (value, set_value) = create_signal(cx, 0);
|
||||
|
||||
// we can remove the event listeners because they're not rendered to HTML
|
||||
view! { cx,
|
||||
<div>
|
||||
<button>"Clear"</button>
|
||||
<button>"-1"</button>
|
||||
<span>"Value: " {value} "!"</span>
|
||||
<button>"+1"</button>
|
||||
</div>
|
||||
}
|
||||
// the view returned an HtmlElement<Div>, which is a smart pointer for
|
||||
// a DOM element. So we can still just call .outer_html()
|
||||
.outer_html()
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
That test involved us manually replicating the `view` that’s inside the component.
|
||||
There's actually an easier way to do this... We can just test against a `<SimpleCounter/>`
|
||||
with the initial value `0`. This is where our wrapping element comes in: I’ll just test
|
||||
the wrapper’s `innerHTML` against another comparison case.
|
||||
|
||||
```rust
|
||||
assert_eq!(test_wrapper.inner_html(), {
|
||||
let comparison_wrapper = document.create_element("section").unwrap();
|
||||
leptos::mount_to(
|
||||
comparison_wrapper.clone().unchecked_into(),
|
||||
|cx| view! { cx, <SimpleCounter initial_value=0 step=1/>},
|
||||
);
|
||||
comparison_wrapper.inner_html()
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This is only a very limited introduction to testing. But I hope it’s useful as you begin to build applications.
|
||||
|
||||
> For more, see [the testing section of the `wasm-bindgen` guide](https://rustwasm.github.io/wasm-bindgen/wasm-bindgen-test/index.html#testing-on-wasm32-unknown-unknown-with-wasm-bindgen-test).
|
||||
@@ -187,6 +187,8 @@ This rerenders `<Small/>` five times, then `<Big/>` infinitely. If they’re
|
||||
loading resources, creating signals, or even just creating DOM nodes, this is
|
||||
unnecessary work.
|
||||
|
||||
### `<Show/>`
|
||||
|
||||
The [`<Show/>`](https://docs.rs/leptos/latest/leptos/fn.Show.html) component is
|
||||
the answer. You pass it a `when` condition function, a `fallback` to be shown if
|
||||
the `when` function returns `false`, and children to be rendered if `when` is `true`.
|
||||
|
||||
126
docs/book/src/view/09_component_children.md
Normal file
126
docs/book/src/view/09_component_children.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Component Children
|
||||
|
||||
It’s pretty common to want to pass children into a component, just as you can pass
|
||||
children into an HTML element. For example, imagine I have a `<FancyForm/>` component
|
||||
that enhances an HTML `<form>`. I need some way to pass all its inputs.
|
||||
|
||||
```rust
|
||||
view! { cx,
|
||||
<Form>
|
||||
<fieldset>
|
||||
<label>
|
||||
"Some Input"
|
||||
<input type="text" name="something"/>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button>"Submit"</button>
|
||||
</Form>
|
||||
}
|
||||
```
|
||||
|
||||
How can you do this in Leptos? There are basically two ways to pass components to
|
||||
other components:
|
||||
|
||||
1. **render props**: properties that are functions that return a view
|
||||
2. the **`children`** prop: a special component property that includes anything
|
||||
you pass as a child to the component.
|
||||
|
||||
In fact, you’ve already seen these both in action in the [`<Show/>`](/view/06_control_flow.html#show) component:
|
||||
|
||||
```rust
|
||||
view! { cx,
|
||||
<Show
|
||||
// `when` is a normal prop
|
||||
when=move || value() > 5
|
||||
// `fallback` is a "render prop": a function that returns a view
|
||||
fallback=|cx| view! { cx, <Small/> }
|
||||
>
|
||||
// `<Big/>` (and anything else here)
|
||||
// will be given to the `children` prop
|
||||
<Big/>
|
||||
</Show>
|
||||
}
|
||||
```
|
||||
|
||||
Let’s define a component that takes some children and a render prop.
|
||||
|
||||
```rust
|
||||
#[component]
|
||||
pub fn TakesChildren<F, IV>(
|
||||
cx: Scope,
|
||||
/// Takes a function (type F) that returns anything that can be
|
||||
/// converted into a View (type IV)
|
||||
render_prop: F,
|
||||
/// `children` takes the `Children` type
|
||||
children: Children,
|
||||
) -> impl IntoView
|
||||
where
|
||||
F: Fn() -> IV,
|
||||
IV: IntoView,
|
||||
{
|
||||
view! { cx,
|
||||
<h2>"Render Prop"</h2>
|
||||
{render_prop()}
|
||||
|
||||
<h2>"Children"</h2>
|
||||
{children(cx)}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`render_prop` and `children` are both functions, so we can call them to generate
|
||||
the appropriate views. `children`, in particular, is an alias for
|
||||
`Box<dyn FnOnce(Scope) -> Fragment>`. (Aren't you glad we named it `Children` instead?)
|
||||
|
||||
> If you need a `Fn` or `FnMut` here because you need to call `children` more than once,
|
||||
> we also provide `ChildrenFn` and `ChildrenMut` aliases.
|
||||
|
||||
We can use the component like this:
|
||||
|
||||
```rust
|
||||
view! { cx,
|
||||
<TakesChildren render_prop=|| view! { cx, <p>"Hi, there!"</p> }>
|
||||
// these get passed to `children`
|
||||
"Some text"
|
||||
<span>"A span"</span>
|
||||
</TakesChildren>
|
||||
}
|
||||
```
|
||||
|
||||
## Manipulating Children
|
||||
|
||||
The [`Fragment`](https://docs.rs/leptos/latest/leptos/struct.Fragment.html) type is
|
||||
basically a way of wrapping a `Vec<View>`. You can insert it anywhere into your view.
|
||||
|
||||
But you can also access those inner views directly to manipulate them. For example, here’s
|
||||
a component that takes its children and turns them into an unordered list.
|
||||
|
||||
```rust
|
||||
#[component]
|
||||
pub fn WrapsChildren(cx: Scope, children: Children) -> impl IntoView {
|
||||
// Fragment has `nodes` field that contains a Vec<View>
|
||||
let children = children(cx)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.map(|child| view! { cx, <li>{child}</li> })
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
view! { cx,
|
||||
<ul>{children}</ul>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Calling it like this will create a list:
|
||||
|
||||
```rust
|
||||
view! { cx,
|
||||
<WrappedChildren>
|
||||
"A"
|
||||
"B"
|
||||
"C"
|
||||
</WrappedChildren>
|
||||
}
|
||||
```
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/9-component-children-2wrdfd?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A12%2C%22endLineNumber%22%3A19%2C%22startColumn%22%3A12%2C%22startLineNumber%22%3A19%7D%5D" width="100%" height="1000px"></iframe>
|
||||
@@ -1,16 +1,86 @@
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
use counter::*;
|
||||
use leptos::*;
|
||||
use web_sys::HtmlElement;
|
||||
use counter::*;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn clear() {
|
||||
let document = leptos::document();
|
||||
let test_wrapper = document.create_element("section").unwrap();
|
||||
document.body().unwrap().append_child(&test_wrapper);
|
||||
|
||||
// start by rendering our counter and mounting it to the DOM
|
||||
// note that we start at the initial value of 10
|
||||
mount_to(
|
||||
test_wrapper.clone().unchecked_into(),
|
||||
|cx| view! { cx, <SimpleCounter initial_value=10 step=1/> },
|
||||
);
|
||||
|
||||
// now we extract the buttons by iterating over the DOM
|
||||
// this would be easier if they had IDs
|
||||
let div = test_wrapper.query_selector("div").unwrap().unwrap();
|
||||
let clear = test_wrapper
|
||||
.query_selector("button")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlElement>();
|
||||
|
||||
// now let's click the `clear` button
|
||||
clear.click();
|
||||
|
||||
// now let's test the <div> against the expected value
|
||||
// we can do this by testing its `outerHTML`
|
||||
assert_eq!(
|
||||
div.outer_html(),
|
||||
// here we spawn a mini reactive system, just to render the
|
||||
// test case
|
||||
run_scope(create_runtime(), |cx| {
|
||||
// it's as if we're creating it with a value of 0, right?
|
||||
let (value, set_value) = create_signal(cx, 0);
|
||||
|
||||
// we can remove the event listeners because they're not rendered to HTML
|
||||
view! { cx,
|
||||
<div>
|
||||
<button>"Clear"</button>
|
||||
<button>"-1"</button>
|
||||
<span>"Value: " {value} "!"</span>
|
||||
<button>"+1"</button>
|
||||
</div>
|
||||
}
|
||||
// the view returned an HtmlElement<Div>, which is a smart pointer for
|
||||
// a DOM element. So we can still just call .outer_html()
|
||||
.outer_html()
|
||||
})
|
||||
);
|
||||
|
||||
// There's actually an easier way to do this...
|
||||
// We can just test against a <SimpleCounter/> with the initial value 0
|
||||
assert_eq!(test_wrapper.inner_html(), {
|
||||
let comparison_wrapper = document.create_element("section").unwrap();
|
||||
leptos::mount_to(
|
||||
comparison_wrapper.clone().unchecked_into(),
|
||||
|cx| view! { cx, <SimpleCounter initial_value=0 step=1/>},
|
||||
);
|
||||
comparison_wrapper.inner_html()
|
||||
});
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn inc() {
|
||||
mount_to_body(|cx| view! { cx, <SimpleCounter initial_value=0 step=1/> });
|
||||
|
||||
let document = leptos::document();
|
||||
let div = document.query_selector("div").unwrap().unwrap();
|
||||
let test_wrapper = document.create_element("section").unwrap();
|
||||
document.body().unwrap().append_child(&test_wrapper);
|
||||
|
||||
mount_to(
|
||||
test_wrapper.clone().unchecked_into(),
|
||||
|cx| view! { cx, <SimpleCounter initial_value=0 step=1/> },
|
||||
);
|
||||
|
||||
// You can do testing with vanilla DOM operations
|
||||
let document = leptos::document();
|
||||
let div = test_wrapper.query_selector("div").unwrap().unwrap();
|
||||
let clear = div
|
||||
.first_child()
|
||||
.unwrap()
|
||||
@@ -47,4 +117,40 @@ fn inc() {
|
||||
clear.click();
|
||||
|
||||
assert_eq!(text.text_content(), Some("Value: 0!".to_string()));
|
||||
|
||||
// Or you can test against a sample view!
|
||||
assert_eq!(
|
||||
div.outer_html(),
|
||||
run_scope(create_runtime(), |cx| {
|
||||
let (value, _) = create_signal(cx, 0);
|
||||
view! { cx,
|
||||
<div>
|
||||
<button>"Clear"</button>
|
||||
<button>"-1"</button>
|
||||
<span>"Value: " {value} "!"</span>
|
||||
<button>"+1"</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
.outer_html())
|
||||
);
|
||||
|
||||
inc.click();
|
||||
|
||||
assert_eq!(
|
||||
div.outer_html(),
|
||||
run_scope(create_runtime(), |cx| {
|
||||
// because we've clicked, it's as if the signal is starting at 1
|
||||
let (value, _) = create_signal(cx, 1);
|
||||
view! { cx,
|
||||
<div>
|
||||
<button>"Clear"</button>
|
||||
<button>"-1"</button>
|
||||
<span>"Value: " {value} "!"</span>
|
||||
<button>"+1"</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
.outer_html())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ leptos_router = { path = "../../router", default-features = false }
|
||||
log = "0.4"
|
||||
simple_logger = "4.0.0"
|
||||
gloo-net = { git = "https://github.com/rustwasm/gloo" }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -97,10 +97,10 @@ pub fn Counter(cx: Scope) -> impl IntoView {
|
||||
|_| get_server_count(),
|
||||
);
|
||||
|
||||
let value = move || counter.read().map(|count| count.unwrap_or(0)).unwrap_or(0);
|
||||
let value = move || counter.read(cx).map(|count| count.unwrap_or(0)).unwrap_or(0);
|
||||
let error_msg = move || {
|
||||
counter
|
||||
.read()
|
||||
.read(cx)
|
||||
.map(|res| match res {
|
||||
Ok(_) => None,
|
||||
Err(e) => Some(e),
|
||||
@@ -143,7 +143,7 @@ pub fn FormCounter(cx: Scope) -> impl IntoView {
|
||||
let value = move || {
|
||||
log::debug!("FormCounter looking for value");
|
||||
counter
|
||||
.read()
|
||||
.read(cx)
|
||||
.map(|n| n.ok())
|
||||
.flatten()
|
||||
.map(|n| n)
|
||||
@@ -194,16 +194,16 @@ pub fn MultiuserCounter(cx: Scope) -> impl IntoView {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut source = gloo_net::eventsource::futures::EventSource::new("/api/events")
|
||||
.expect_throw("couldn't connect to SSE stream");
|
||||
.expect("couldn't connect to SSE stream");
|
||||
let s = create_signal_from_stream(
|
||||
cx,
|
||||
source.subscribe("message").unwrap().map(|value| {
|
||||
value
|
||||
.expect_throw("no message event")
|
||||
.expect("no message event")
|
||||
.1
|
||||
.data()
|
||||
.as_string()
|
||||
.expect_throw("expected string value")
|
||||
.expect("expected string value")
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use leptos::{ev, *};
|
||||
use leptos::{ev, html::*, *};
|
||||
|
||||
pub struct Props {
|
||||
/// The starting value for the counter
|
||||
@@ -25,7 +25,9 @@ pub fn view(cx: Scope, props: Props) -> impl IntoView {
|
||||
.child((
|
||||
cx,
|
||||
button(cx)
|
||||
.on(ev::click, move |_| set_value.update(|value| *value -= step))
|
||||
.on(ev::click, move |_| {
|
||||
set_value.update(|value| *value -= step)
|
||||
})
|
||||
.child((cx, "-1")),
|
||||
))
|
||||
.child((
|
||||
@@ -38,7 +40,9 @@ pub fn view(cx: Scope, props: Props) -> impl IntoView {
|
||||
.child((
|
||||
cx,
|
||||
button(cx)
|
||||
.on(ev::click, move |_| set_value.update(|value| *value += step))
|
||||
.on(ev::click, move |_| {
|
||||
set_value.update(|value| *value += step)
|
||||
})
|
||||
.child((cx, "+1")),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use leptos::*;
|
||||
use leptos::{For, ForProps};
|
||||
use leptos::{For, ForProps, *};
|
||||
|
||||
const MANY_COUNTERS: usize = 1000;
|
||||
|
||||
@@ -66,9 +65,8 @@ pub fn Counters(cx: Scope) -> impl IntoView {
|
||||
<For
|
||||
each=counters
|
||||
key=|counter| counter.0
|
||||
view=move |(id, (value, set_value)): (usize, (ReadSignal<i32>, WriteSignal<i32>))| {
|
||||
view! {
|
||||
cx,
|
||||
view=move |cx, (id, (value, set_value)): (usize, (ReadSignal<i32>, WriteSignal<i32>))| {
|
||||
view! { cx,
|
||||
<Counter id value set_value/>
|
||||
}
|
||||
}
|
||||
@@ -85,9 +83,11 @@ fn Counter(
|
||||
value: ReadSignal<i32>,
|
||||
set_value: WriteSignal<i32>,
|
||||
) -> impl IntoView {
|
||||
let CounterUpdater { set_counters } = use_context(cx).unwrap_throw();
|
||||
let CounterUpdater { set_counters } = use_context(cx).unwrap();
|
||||
|
||||
let input = move |ev| set_value(event_target_value(&ev).parse::<i32>().unwrap_or_default());
|
||||
let input = move |ev| {
|
||||
set_value(event_target_value(&ev).parse::<i32>().unwrap_or_default())
|
||||
};
|
||||
|
||||
// just an example of how a cleanup function works
|
||||
// this will run when the scope is disposed, i.e., when this row is deleted
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn Counters(cx: Scope) -> impl IntoView {
|
||||
<For
|
||||
each={move || counters.get()}
|
||||
key={|counter| counter.0}
|
||||
view=move |(id, (value, set_value))| {
|
||||
view=move |cx, (id, (value, set_value))| {
|
||||
view! {
|
||||
cx,
|
||||
<Counter id value set_value/>
|
||||
@@ -91,9 +91,12 @@ fn Counter(
|
||||
value: ReadSignal<i32>,
|
||||
set_value: WriteSignal<i32>,
|
||||
) -> impl IntoView {
|
||||
let CounterUpdater { set_counters } = use_context(cx).unwrap_throw();
|
||||
let CounterUpdater { set_counters } = use_context(cx).unwrap();
|
||||
|
||||
let input = move |ev| set_value.set(event_target_value(&ev).parse::<i32>().unwrap_or_default());
|
||||
let input = move |ev| {
|
||||
set_value
|
||||
.set(event_target_value(&ev).parse::<i32>().unwrap_or_default())
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<li>
|
||||
|
||||
10
examples/error_boundary/Cargo.toml
Normal file
10
examples/error_boundary/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "error_boundary"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
leptos = { path = "../../leptos" }
|
||||
console_log = "0.2"
|
||||
log = "0.4"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
9
examples/error_boundary/Makefile.toml
Normal file
9
examples/error_boundary/Makefile.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[tasks.build]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
7
examples/error_boundary/README.md
Normal file
7
examples/error_boundary/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Leptos `<ErrorBoundary/>` Example
|
||||
|
||||
This example shows how to handle basic errors using Leptos.
|
||||
|
||||
To run it, just issue the `trunk serve --open` command in the example root. This will build the app, run it, and open a new browser to serve it.
|
||||
|
||||
> If you don't have `trunk` installed, [click here for install instructions.](https://trunkrs.dev/)
|
||||
8
examples/error_boundary/index.html
Normal file
8
examples/error_boundary/index.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z"/>
|
||||
<link data-trunk rel="icon" type="image/ico" href="/public/favicon.ico"/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
BIN
examples/error_boundary/public/favicon.ico
Normal file
BIN
examples/error_boundary/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
48
examples/error_boundary/src/lib.rs
Normal file
48
examples/error_boundary/src/lib.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use leptos::*;
|
||||
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
let (value, set_value) = create_signal(cx, Ok(0));
|
||||
|
||||
// when input changes, try to parse a number from the input
|
||||
let on_input = move |ev| set_value(event_target_value(&ev).parse::<i32>());
|
||||
|
||||
view! { cx,
|
||||
<h1>"Error Handling"</h1>
|
||||
<label>
|
||||
"Type a number (or something that's not a number!)"
|
||||
<input type="number" on:input=on_input/>
|
||||
// If an `Err(_) had been rendered inside the <ErrorBoundary/>,
|
||||
// the fallback will be displayed. Otherwise, the children of the
|
||||
// <ErrorBoundary/> will be displayed.
|
||||
<ErrorBoundary
|
||||
// the fallback receives a signal containing current errors
|
||||
fallback=|cx, errors| view! { cx,
|
||||
<div class="error">
|
||||
<p>"Not a number! Errors: "</p>
|
||||
// we can render a list of errors
|
||||
// as strings, if we'd like
|
||||
<ul>
|
||||
{move || errors.get()
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { cx, <li>{e.to_string()}</li>})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
"You entered "
|
||||
// because `value` is `Result<i32, _>`,
|
||||
// it will render the `i32` if it is `Ok`,
|
||||
// and render nothing and trigger the error boundary
|
||||
// if it is `Err`. It's a signal, so this will dynamically
|
||||
// update when `value` changes
|
||||
<strong>{value}</strong>
|
||||
</p>
|
||||
</ErrorBoundary>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
12
examples/error_boundary/src/main.rs
Normal file
12
examples/error_boundary/src/main.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use error_boundary::*;
|
||||
use leptos::*;
|
||||
|
||||
pub fn main() {
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(|cx| {
|
||||
view! { cx,
|
||||
<App/>
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -32,45 +32,49 @@ tokio = { version = "1.22.0", features = ["full"], optional = true }
|
||||
http = { version = "0.2.8" }
|
||||
thiserror = "1.0.38"
|
||||
tracing = "0.1.37"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["csr"]
|
||||
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = ["dep:axum", "dep:tower", "dep:tower-http", "dep:tokio", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "dep:leptos_axum"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = [
|
||||
"axum",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tokio",
|
||||
"leptos_axum",
|
||||
]
|
||||
denylist = ["axum", "tower", "tower-http", "tokio", "leptos_axum"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "errors_axum"
|
||||
output-name = "errors_axum"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "./style.css"
|
||||
# [Optional] Files in the asset-dir will be copied to the site-root directory
|
||||
assets-dir = "public"
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "127.0.0.1:3000"
|
||||
site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::errors::AppError;
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::Errors;
|
||||
use leptos::*;
|
||||
|
||||
use leptos::{Errors, *};
|
||||
#[cfg(feature = "ssr")]
|
||||
use leptos_axum::ResponseOptions;
|
||||
|
||||
@@ -23,12 +21,11 @@ pub fn ErrorTemplate(
|
||||
};
|
||||
|
||||
// Get Errors from Signal
|
||||
let errors = errors.get().0;
|
||||
|
||||
// Downcast lets us take a type that implements `std::error::Error`
|
||||
let errors: Vec<AppError> = errors
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned())
|
||||
.filter_map(|(_, v)| v.downcast_ref::<AppError>().cloned())
|
||||
.collect();
|
||||
log!("Errors: {errors:#?}");
|
||||
|
||||
@@ -47,9 +44,9 @@ pub fn ErrorTemplate(
|
||||
// a function that returns the items we're iterating over; a signal is fine
|
||||
each= move || {errors.clone().into_iter().enumerate()}
|
||||
// a unique key for each item as a reference
|
||||
key=|(index, _error)| *index
|
||||
key=|(index, _)| *index
|
||||
// renders each item to a view
|
||||
view= move |error| {
|
||||
view=move |cx, error| {
|
||||
let error_string = error.1.to_string();
|
||||
let error_code= error.1.status_code();
|
||||
view! { cx,
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
max-width: 250px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid red;
|
||||
color: red;
|
||||
background-color: lightpink;
|
||||
}
|
||||
</style>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -1,3 +1,4 @@
|
||||
use anyhow::Result;
|
||||
use leptos::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -6,18 +7,18 @@ pub struct Cat {
|
||||
url: String,
|
||||
}
|
||||
|
||||
async fn fetch_cats(count: u32) -> Result<Vec<String>, ()> {
|
||||
async fn fetch_cats(count: u32) -> Result<Vec<String>> {
|
||||
if count > 0 {
|
||||
// make the request
|
||||
let res = reqwasm::http::Request::get(&format!(
|
||||
"https://api.thecatapi.com/v1/images/search?limit={}",
|
||||
count
|
||||
"https://api.thecatapi.com/v1/images/search?limit={count}",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?
|
||||
.await?
|
||||
// convert it to JSON
|
||||
.json::<Vec<Cat>>()
|
||||
.await
|
||||
.map_err(|_| ())?
|
||||
.await?
|
||||
// extract the URL field for each cat
|
||||
.into_iter()
|
||||
.map(|cat| cat.url)
|
||||
.collect::<Vec<_>>();
|
||||
@@ -29,9 +30,45 @@ async fn fetch_cats(count: u32) -> Result<Vec<String>, ()> {
|
||||
|
||||
pub fn fetch_example(cx: Scope) -> impl IntoView {
|
||||
let (cat_count, set_cat_count) = create_signal::<u32>(cx, 1);
|
||||
let cats = create_resource(cx, cat_count, |count| fetch_cats(count));
|
||||
|
||||
view! { cx,
|
||||
// we use local_resource here because
|
||||
// 1) anyhow::Result isn't serializable/deserializable
|
||||
// 2) we're not doing server-side rendering in this example anyway
|
||||
// (during SSR, create_resource will begin loading on the server and resolve on the client)
|
||||
let cats = create_local_resource(cx, cat_count, fetch_cats);
|
||||
|
||||
let fallback = move |cx, errors: RwSignal<Errors>| {
|
||||
let error_list = move || {
|
||||
errors.with(|errors| {
|
||||
errors
|
||||
.iter()
|
||||
.map(|(_, e)| view! { cx, <li>{e.to_string()}</li>})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<div class="error">
|
||||
<h2>"Error"</h2>
|
||||
<ul>{error_list}</ul>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
// the renderer can handle Option<_> and Result<_> states
|
||||
// by displaying nothing for None if the resource is still loading
|
||||
// and by using the ErrorBoundary fallback to catch Err(_)
|
||||
// so we'll just implement our happy path and let the framework handle the rest
|
||||
let cats_view = move || {
|
||||
cats.with(cx, |data| {
|
||||
data.iter()
|
||||
.flatten()
|
||||
.map(|cat| view! { cx, <img src={cat}/> })
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<div>
|
||||
<label>
|
||||
"How many cats would you like?"
|
||||
@@ -43,25 +80,11 @@ pub fn fetch_example(cx: Scope) -> impl IntoView {
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Transition fallback=move || view! { cx, <div>"Loading (Suspense Fallback)..."</div>}>
|
||||
{move || {
|
||||
cats.read().map(|data| match data {
|
||||
Err(_) => view! { cx, <pre>"Error"</pre> }.into_view(cx),
|
||||
Ok(cats) => view! { cx,
|
||||
<div>{
|
||||
cats.iter()
|
||||
.map(|src| {
|
||||
view! { cx,
|
||||
<img src={src}/>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}</div>
|
||||
}.into_view(cx),
|
||||
})
|
||||
}
|
||||
}
|
||||
</Transition>
|
||||
<ErrorBoundary fallback>
|
||||
<Transition fallback=move || view! { cx, <div>"Loading (Suspense Fallback)..."</div>}>
|
||||
{cats_view}
|
||||
</Transition>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
|
||||
let (pending, set_pending) = create_signal(cx, false);
|
||||
|
||||
let hide_more_link =
|
||||
move || pending() || stories.read().unwrap_or(None).unwrap_or_default().len() < 28;
|
||||
move || pending() || stories.read(cx).unwrap_or(None).unwrap_or_default().len() < 28;
|
||||
|
||||
view! {
|
||||
cx,
|
||||
@@ -82,7 +82,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
|
||||
fallback=move || view! { cx, <p>"Loading..."</p> }
|
||||
set_pending=set_pending.into()
|
||||
>
|
||||
{move || match stories.read() {
|
||||
{move || match stories.read(cx) {
|
||||
None => None,
|
||||
Some(None) => Some(view! { cx, <p>"Error loading stories."</p> }.into_any()),
|
||||
Some(Some(stories)) => {
|
||||
@@ -91,7 +91,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
|
||||
<For
|
||||
each=move || stories.clone()
|
||||
key=|story| story.id
|
||||
view=move |story: api::Story| {
|
||||
view=move |cx, story: api::Story| {
|
||||
view! { cx,
|
||||
<Story story/>
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ pub fn Story(cx: Scope) -> impl IntoView {
|
||||
}
|
||||
},
|
||||
);
|
||||
let meta_description = move || story.read().and_then(|story| story.map(|story| story.title)).unwrap_or_else(|| "Loading story...".to_string());
|
||||
let meta_description = move || story.read(cx).and_then(|story| story.map(|story| story.title)).unwrap_or_else(|| "Loading story...".to_string());
|
||||
|
||||
view! { cx,
|
||||
<>
|
||||
<Meta name="description" content=meta_description/>
|
||||
<Suspense fallback=|| view! { cx, "Loading..." }>
|
||||
{move || story.read().map(|story| match story {
|
||||
{move || story.read(cx).map(|story| match story {
|
||||
None => view! { cx, <div class="item-view">"Error loading this story."</div> },
|
||||
Some(story) => view! { cx,
|
||||
<div class="item-view">
|
||||
@@ -53,7 +53,7 @@ pub fn Story(cx: Scope) -> impl IntoView {
|
||||
<For
|
||||
each=move || story.comments.clone().unwrap_or_default()
|
||||
key=|comment| comment.id
|
||||
view=move |comment| view! { cx, <Comment comment /> }
|
||||
view=move |cx, comment| view! { cx, <Comment comment /> }
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ pub fn Comment(cx: Scope, comment: api::Comment) -> impl IntoView {
|
||||
<For
|
||||
each=move || comments.clone()
|
||||
key=|comment| comment.id
|
||||
view=move |comment: api::Comment| view! { cx, <Comment comment /> }
|
||||
view=move |cx, comment: api::Comment| view! { cx, <Comment comment /> }
|
||||
/>
|
||||
</ul>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn User(cx: Scope) -> impl IntoView {
|
||||
view! { cx,
|
||||
<div class="user-view">
|
||||
<Suspense fallback=|| view! { cx, "Loading..." }>
|
||||
{move || user.read().map(|user| match user {
|
||||
{move || user.read(cx).map(|user| match user {
|
||||
None => view! { cx, <h1>"User not found."</h1> }.into_any(),
|
||||
Some(user) => view! { cx,
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z"/>
|
||||
<link data-trunk rel="css" href="./static/style.css"/>
|
||||
<link data-trunk rel="css" href="/style.css"/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -1,5 +1,7 @@
|
||||
use leptos::Errors;
|
||||
use leptos::{view, For, ForProps, IntoView, RwSignal, Scope, View};
|
||||
use leptos::{
|
||||
signal_prelude::*, view, Errors, For, ForProps, IntoView, RwSignal, Scope,
|
||||
View,
|
||||
};
|
||||
|
||||
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
|
||||
// here than just displaying them
|
||||
@@ -7,21 +9,22 @@ pub fn error_template(cx: Scope, errors: Option<RwSignal<Errors>>) -> View {
|
||||
let Some(errors) = errors else {
|
||||
panic!("No Errors found and we expected errors!");
|
||||
};
|
||||
|
||||
view! {cx,
|
||||
<h1>"Errors"</h1>
|
||||
<For
|
||||
// a function that returns the items we're iterating over; a signal is fine
|
||||
each= move || {errors.get().0.into_iter()}
|
||||
// a unique key for each item as a reference
|
||||
key=|error| error.0.clone()
|
||||
// renders each item to a view
|
||||
view= move |error| {
|
||||
let error_string = error.1.to_string();
|
||||
view! {
|
||||
cx,
|
||||
<p>"Error: " {error_string}</p>
|
||||
<h1>"Errors"</h1>
|
||||
<For
|
||||
// a function that returns the items we're iterating over; a signal is fine
|
||||
each=errors
|
||||
// a unique key for each item as a reference
|
||||
key=|(key, _)| key.clone()
|
||||
// renders each item to a view
|
||||
view= move |cx, (_, error)| {
|
||||
let error_string = error.to_string();
|
||||
view! {
|
||||
cx,
|
||||
<p>"Error: " {error_string}</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
.into_view(cx)
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
|
||||
let (pending, set_pending) = create_signal(cx, false);
|
||||
|
||||
let hide_more_link =
|
||||
move || pending() || stories.read().unwrap_or(None).unwrap_or_default().len() < 28;
|
||||
move || pending() || stories.read(cx).unwrap_or(None).unwrap_or_default().len() < 28;
|
||||
|
||||
view! {
|
||||
cx,
|
||||
@@ -82,7 +82,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
|
||||
fallback=move || view! { cx, <p>"Loading..."</p> }
|
||||
set_pending=set_pending.into()
|
||||
>
|
||||
{move || match stories.read() {
|
||||
{move || match stories.read(cx) {
|
||||
None => None,
|
||||
Some(None) => Some(view! { cx, <p>"Error loading stories."</p> }.into_any()),
|
||||
Some(Some(stories)) => {
|
||||
@@ -91,7 +91,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
|
||||
<For
|
||||
each=move || stories.clone()
|
||||
key=|story| story.id
|
||||
view=move |story: api::Story| {
|
||||
view=move |cx, story: api::Story| {
|
||||
view! { cx,
|
||||
<Story story/>
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ pub fn Story(cx: Scope) -> impl IntoView {
|
||||
}
|
||||
},
|
||||
);
|
||||
let meta_description = move || story.read().and_then(|story| story.map(|story| story.title)).unwrap_or_else(|| "Loading story...".to_string());
|
||||
let meta_description = move || story.read(cx).and_then(|story| story.map(|story| story.title)).unwrap_or_else(|| "Loading story...".to_string());
|
||||
|
||||
view! { cx,
|
||||
<>
|
||||
<Meta name="description" content=meta_description/>
|
||||
<Suspense fallback=|| view! { cx, "Loading..." }>
|
||||
{move || story.read().map(|story| match story {
|
||||
{move || story.read(cx).map(|story| match story {
|
||||
None => view! { cx, <div class="item-view">"Error loading this story."</div> },
|
||||
Some(story) => view! { cx,
|
||||
<div class="item-view">
|
||||
@@ -53,7 +53,7 @@ pub fn Story(cx: Scope) -> impl IntoView {
|
||||
<For
|
||||
each=move || story.comments.clone().unwrap_or_default()
|
||||
key=|comment| comment.id
|
||||
view=move |comment| view! { cx, <Comment comment /> }
|
||||
view=move |cx, comment| view! { cx, <Comment comment /> }
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ pub fn Comment(cx: Scope, comment: api::Comment) -> impl IntoView {
|
||||
<For
|
||||
each=move || comments.clone()
|
||||
key=|comment| comment.id
|
||||
view=move |comment: api::Comment| view! { cx, <Comment comment /> }
|
||||
view=move |cx, comment: api::Comment| view! { cx, <Comment comment /> }
|
||||
/>
|
||||
</ul>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn User(cx: Scope) -> impl IntoView {
|
||||
view! { cx,
|
||||
<div class="user-view">
|
||||
<Suspense fallback=|| view! { cx, "Loading..." }>
|
||||
{move || user.read().map(|user| match user {
|
||||
{move || user.read(cx).map(|user| match user {
|
||||
None => view! { cx, <h1>"User not found."</h1> }.into_any(),
|
||||
Some(user) => view! { cx,
|
||||
<div>
|
||||
|
||||
7
examples/login_with_token_csr_only/Cargo.toml
Normal file
7
examples/login_with_token_csr_only/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[workspace]
|
||||
members = ["client", "api-boundary", "server"]
|
||||
|
||||
[patch.crates-io]
|
||||
leptos = { path = "../../leptos" }
|
||||
leptos_router = { path = "../../router" }
|
||||
api-boundary = { path = "api-boundary" }
|
||||
23
examples/login_with_token_csr_only/README.md
Normal file
23
examples/login_with_token_csr_only/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Leptos Login Example
|
||||
|
||||
This example demonstrates a scenario of a client-side rendered application
|
||||
that uses uses an existing API that you cannot or do not want to change.
|
||||
The authentications of this example are done using an API token.
|
||||
|
||||
## Run
|
||||
|
||||
First start the example server:
|
||||
|
||||
```
|
||||
cd server/ && cargo run
|
||||
```
|
||||
|
||||
then use [`trunk`](https://trunkrs.dev) to serve the SPA:
|
||||
|
||||
```
|
||||
cd client/ && trunk serve
|
||||
```
|
||||
|
||||
finally you can visit the web application at `http://localhost:8080`
|
||||
|
||||
The `api-boundary` crate contains data structures that are used by the server and the client.
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "api-boundary"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
22
examples/login_with_token_csr_only/api-boundary/src/lib.rs
Normal file
22
examples/login_with_token_csr_only/api-boundary/src/lib.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ApiToken {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Error {
|
||||
pub message: String,
|
||||
}
|
||||
19
examples/login_with_token_csr_only/client/Cargo.toml
Normal file
19
examples/login_with_token_csr_only/client/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "client"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
api-boundary = "*"
|
||||
|
||||
leptos = { version = "0.2.0-alpha2", features = ["stable"] }
|
||||
leptos_router = { version = "0.2.0-alpha2", features = ["stable", "csr"] }
|
||||
|
||||
log = "0.4"
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "0.2"
|
||||
gloo-net = "0.2"
|
||||
gloo-storage = "0.2"
|
||||
serde = "1.0"
|
||||
thiserror = "1.0"
|
||||
3
examples/login_with_token_csr_only/client/Trunk.toml
Normal file
3
examples/login_with_token_csr_only/client/Trunk.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[[proxy]]
|
||||
rewrite = "/api/"
|
||||
backend = "http://0.0.0.0:3000/"
|
||||
7
examples/login_with_token_csr_only/client/index.html
Normal file
7
examples/login_with_token_csr_only/client/index.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
94
examples/login_with_token_csr_only/client/src/api.rs
Normal file
94
examples/login_with_token_csr_only/client/src/api.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use gloo_net::http::{Request, Response};
|
||||
use serde::de::DeserializeOwned;
|
||||
use thiserror::Error;
|
||||
|
||||
use api_boundary::*;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct UnauthorizedApi {
|
||||
url: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthorizedApi {
|
||||
url: &'static str,
|
||||
token: ApiToken,
|
||||
}
|
||||
|
||||
impl UnauthorizedApi {
|
||||
pub const fn new(url: &'static str) -> Self {
|
||||
Self { url }
|
||||
}
|
||||
pub async fn register(&self, credentials: &Credentials) -> Result<()> {
|
||||
let url = format!("{}/users", self.url);
|
||||
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||
into_json(response).await
|
||||
}
|
||||
pub async fn login(
|
||||
&self,
|
||||
credentials: &Credentials,
|
||||
) -> Result<AuthorizedApi> {
|
||||
let url = format!("{}/login", self.url);
|
||||
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||
let token = into_json(response).await?;
|
||||
Ok(AuthorizedApi::new(self.url, token))
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthorizedApi {
|
||||
pub const fn new(url: &'static str, token: ApiToken) -> Self {
|
||||
Self { url, token }
|
||||
}
|
||||
fn auth_header_value(&self) -> String {
|
||||
format!("Bearer {}", self.token.token)
|
||||
}
|
||||
async fn send<T>(&self, req: Request) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let response = req
|
||||
.header("Authorization", &self.auth_header_value())
|
||||
.send()
|
||||
.await?;
|
||||
into_json(response).await
|
||||
}
|
||||
pub async fn logout(&self) -> Result<()> {
|
||||
let url = format!("{}/logout", self.url);
|
||||
self.send(Request::post(&url)).await
|
||||
}
|
||||
pub async fn user_info(&self) -> Result<UserInfo> {
|
||||
let url = format!("{}/users", self.url);
|
||||
self.send(Request::get(&url)).await
|
||||
}
|
||||
pub fn token(&self) -> &ApiToken {
|
||||
&self.token
|
||||
}
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Fetch(#[from] gloo_net::Error),
|
||||
#[error("{0:?}")]
|
||||
Api(api_boundary::Error),
|
||||
}
|
||||
|
||||
impl From<api_boundary::Error> for Error {
|
||||
fn from(e: api_boundary::Error) -> Self {
|
||||
Self::Api(e)
|
||||
}
|
||||
}
|
||||
|
||||
async fn into_json<T>(response: Response) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
// ensure we've got 2xx status
|
||||
if response.ok() {
|
||||
Ok(response.json().await?)
|
||||
} else {
|
||||
Err(response.json::<api_boundary::Error>().await?.into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use leptos::{ev, *};
|
||||
|
||||
#[component]
|
||||
pub fn CredentialsForm(
|
||||
cx: Scope,
|
||||
title: &'static str,
|
||||
action_label: &'static str,
|
||||
action: Action<(String, String), ()>,
|
||||
error: Signal<Option<String>>,
|
||||
disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let (password, set_password) = create_signal(cx, String::new());
|
||||
let (email, set_email) = create_signal(cx, String::new());
|
||||
|
||||
let dispatch_action =
|
||||
move || action.dispatch((email.get(), password.get()));
|
||||
|
||||
let button_is_disabled = Signal::derive(cx, move || {
|
||||
disabled.get() || password.get().is_empty() || email.get().is_empty()
|
||||
});
|
||||
|
||||
view! { cx,
|
||||
<form on:submit=|ev|ev.prevent_default()>
|
||||
<p>{ title }</p>
|
||||
{move || error.get().map(|err| view!{ cx,
|
||||
<p style ="color:red;" >{ err }</p>
|
||||
})}
|
||||
<input
|
||||
type = "email"
|
||||
required
|
||||
placeholder = "Email address"
|
||||
prop:disabled = move || disabled.get()
|
||||
on:keyup = move |ev: ev::KeyboardEvent| {
|
||||
let val = event_target_value(&ev);
|
||||
set_email.update(|v|*v = val);
|
||||
}
|
||||
// The `change` event fires when the browser fills the form automatically,
|
||||
on:change = move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
set_email.update(|v|*v = val);
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type = "password"
|
||||
required
|
||||
placeholder = "Password"
|
||||
prop:disabled = move || disabled.get()
|
||||
on:keyup = move |ev: ev::KeyboardEvent| {
|
||||
match &*ev.key() {
|
||||
"Enter" => {
|
||||
dispatch_action();
|
||||
}
|
||||
_=> {
|
||||
let val = event_target_value(&ev);
|
||||
set_password.update(|p|*p = val);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The `change` event fires when the browser fills the form automatically,
|
||||
on:change = move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
set_password.update(|p|*p = val);
|
||||
}
|
||||
/>
|
||||
<button
|
||||
prop:disabled = move || button_is_disabled.get()
|
||||
on:click = move |_| dispatch_action()
|
||||
>
|
||||
{ action_label }
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod credentials;
|
||||
pub mod navbar;
|
||||
|
||||
pub use self::{credentials::*, navbar::*};
|
||||
@@ -0,0 +1,32 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use crate::Page;
|
||||
|
||||
#[component]
|
||||
pub fn NavBar<F>(
|
||||
cx: Scope,
|
||||
logged_in: Signal<bool>,
|
||||
on_logout: F,
|
||||
) -> impl IntoView
|
||||
where
|
||||
F: Fn() + 'static + Clone,
|
||||
{
|
||||
view! { cx,
|
||||
<nav>
|
||||
<Show
|
||||
when = move || logged_in.get()
|
||||
fallback = |cx| view! { cx,
|
||||
<A href=Page::Login.path() >"Login"</A>
|
||||
" | "
|
||||
<A href=Page::Register.path() >"Register"</A>
|
||||
}
|
||||
>
|
||||
<a href="#" on:click={
|
||||
let on_logout = on_logout.clone();
|
||||
move |_| on_logout()
|
||||
}>"Logout"</a>
|
||||
</Show>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
130
examples/login_with_token_csr_only/client/src/lib.rs
Normal file
130
examples/login_with_token_csr_only/client/src/lib.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use api_boundary::*;
|
||||
|
||||
mod api;
|
||||
mod components;
|
||||
mod pages;
|
||||
|
||||
use self::{components::*, pages::*};
|
||||
|
||||
const DEFAULT_API_URL: &str = "/api";
|
||||
const API_TOKEN_STORAGE_KEY: &str = "api-token";
|
||||
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
// -- signals -- //
|
||||
|
||||
let authorized_api = create_rw_signal(cx, None::<api::AuthorizedApi>);
|
||||
let user_info = create_rw_signal(cx, None::<UserInfo>);
|
||||
let logged_in = Signal::derive(cx, move || authorized_api.get().is_some());
|
||||
|
||||
// -- actions -- //
|
||||
|
||||
let fetch_user_info = create_action(cx, move |_| async move {
|
||||
match authorized_api.get() {
|
||||
Some(api) => match api.user_info().await {
|
||||
Ok(info) => {
|
||||
user_info.update(|i| *i = Some(info));
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Unable to fetch user info: {err}")
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::error!("Unable to fetch user info: not logged in")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let logout = create_action(cx, move |_| async move {
|
||||
match authorized_api.get() {
|
||||
Some(api) => match api.logout().await {
|
||||
Ok(_) => {
|
||||
authorized_api.update(|a| *a = None);
|
||||
user_info.update(|i| *i = None);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Unable to logout: {err}")
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::error!("Unable to logout user: not logged in")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// -- callbacks -- //
|
||||
|
||||
let on_logout = move || {
|
||||
logout.dispatch(());
|
||||
};
|
||||
|
||||
// -- init API -- //
|
||||
|
||||
let unauthorized_api = api::UnauthorizedApi::new(DEFAULT_API_URL);
|
||||
if let Ok(token) = LocalStorage::get(API_TOKEN_STORAGE_KEY) {
|
||||
let api = api::AuthorizedApi::new(DEFAULT_API_URL, token);
|
||||
authorized_api.update(|a| *a = Some(api));
|
||||
fetch_user_info.dispatch(());
|
||||
}
|
||||
|
||||
log::debug!("User is logged in: {}", logged_in.get());
|
||||
|
||||
// -- effects -- //
|
||||
|
||||
create_effect(cx, move |_| {
|
||||
log::debug!("API authorization state changed");
|
||||
match authorized_api.get() {
|
||||
Some(api) => {
|
||||
log::debug!(
|
||||
"API is now authorized: save token in LocalStorage"
|
||||
);
|
||||
LocalStorage::set(API_TOKEN_STORAGE_KEY, api.token())
|
||||
.expect("LocalStorage::set");
|
||||
}
|
||||
None => {
|
||||
log::debug!("API is no longer authorized: delete token from LocalStorage");
|
||||
LocalStorage::delete(API_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! { cx,
|
||||
<Router>
|
||||
<NavBar logged_in on_logout />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route
|
||||
path=Page::Home.path()
|
||||
view=move |cx| view! { cx,
|
||||
<Home user_info = user_info.into() />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path=Page::Login.path()
|
||||
view=move |cx| view! { cx,
|
||||
<Login
|
||||
api = unauthorized_api
|
||||
on_success = move |api| {
|
||||
log::info!("Successfully logged in");
|
||||
authorized_api.update(|v| *v = Some(api));
|
||||
let navigate = use_navigate(cx);
|
||||
navigate(Page::Home.path(), Default::default()).expect("Home route");
|
||||
fetch_user_info.dispatch(());
|
||||
} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path=Page::Register.path()
|
||||
view=move |cx| view! { cx,
|
||||
<Register api = unauthorized_api />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
9
examples/login_with_token_csr_only/client/src/main.rs
Normal file
9
examples/login_with_token_csr_only/client/src/main.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use leptos::*;
|
||||
|
||||
use client::*;
|
||||
|
||||
pub fn main() {
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(|cx| view! { cx, <App /> })
|
||||
}
|
||||
20
examples/login_with_token_csr_only/client/src/pages/home.rs
Normal file
20
examples/login_with_token_csr_only/client/src/pages/home.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use crate::Page;
|
||||
use api_boundary::UserInfo;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
#[component]
|
||||
pub fn Home(cx: Scope, user_info: Signal<Option<UserInfo>>) -> impl IntoView {
|
||||
view! { cx,
|
||||
<h2>"Leptos Login example"</h2>
|
||||
{move || match user_info.get() {
|
||||
Some(info) => view!{ cx,
|
||||
<p>"You are logged in with "{ info.email }"."</p>
|
||||
}.into_view(cx),
|
||||
None => view!{ cx,
|
||||
<p>"You are not logged in."</p>
|
||||
<A href=Page::Login.path() >"Login now."</A>
|
||||
}.into_view(cx)
|
||||
}}
|
||||
}
|
||||
}
|
||||
66
examples/login_with_token_csr_only/client/src/pages/login.rs
Normal file
66
examples/login_with_token_csr_only/client/src/pages/login.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use api_boundary::*;
|
||||
|
||||
use crate::{
|
||||
api::{self, AuthorizedApi, UnauthorizedApi},
|
||||
components::credentials::*,
|
||||
Page,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Login<F>(cx: Scope, api: UnauthorizedApi, on_success: F) -> impl IntoView
|
||||
where
|
||||
F: Fn(AuthorizedApi) + 'static + Clone,
|
||||
{
|
||||
let (login_error, set_login_error) = create_signal(cx, None::<String>);
|
||||
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
|
||||
|
||||
let login_action =
|
||||
create_action(cx, move |(email, password): &(String, String)| {
|
||||
log::debug!("Try to login with {email}");
|
||||
let email = email.to_string();
|
||||
let password = password.to_string();
|
||||
let credentials = Credentials { email, password };
|
||||
let on_success = on_success.clone();
|
||||
async move {
|
||||
set_wait_for_response.update(|w| *w = true);
|
||||
let result = api.login(&credentials).await;
|
||||
set_wait_for_response.update(|w| *w = false);
|
||||
match result {
|
||||
Ok(res) => {
|
||||
set_login_error.update(|e| *e = None);
|
||||
on_success(res);
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = match err {
|
||||
api::Error::Fetch(js_err) => {
|
||||
format!("{js_err:?}")
|
||||
}
|
||||
api::Error::Api(err) => err.message,
|
||||
};
|
||||
error!(
|
||||
"Unable to login with {}: {msg}",
|
||||
credentials.email
|
||||
);
|
||||
set_login_error.update(|e| *e = Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let disabled = Signal::derive(cx, move || wait_for_response.get());
|
||||
|
||||
view! { cx,
|
||||
<CredentialsForm
|
||||
title = "Please login to your account"
|
||||
action_label = "Login"
|
||||
action = login_action
|
||||
error = login_error.into()
|
||||
disabled
|
||||
/>
|
||||
<p>"Don't have an account?"</p>
|
||||
<A href=Page::Register.path()>"Register"</A>
|
||||
}
|
||||
}
|
||||
23
examples/login_with_token_csr_only/client/src/pages/mod.rs
Normal file
23
examples/login_with_token_csr_only/client/src/pages/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
|
||||
pub use self::{home::*, login::*, register::*};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum Page {
|
||||
#[default]
|
||||
Home,
|
||||
Login,
|
||||
Register,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub fn path(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "/",
|
||||
Self::Login => "/login",
|
||||
Self::Register => "/register",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use api_boundary::*;
|
||||
|
||||
use crate::{
|
||||
api::{self, UnauthorizedApi},
|
||||
components::credentials::*,
|
||||
Page,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Register(cx: Scope, api: UnauthorizedApi) -> impl IntoView {
|
||||
let (register_response, set_register_response) =
|
||||
create_signal(cx, None::<()>);
|
||||
let (register_error, set_register_error) =
|
||||
create_signal(cx, None::<String>);
|
||||
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
|
||||
|
||||
let register_action =
|
||||
create_action(cx, move |(email, password): &(String, String)| {
|
||||
let email = email.to_string();
|
||||
let password = password.to_string();
|
||||
let credentials = Credentials { email, password };
|
||||
log!("Try to register new account for {}", credentials.email);
|
||||
async move {
|
||||
set_wait_for_response.update(|w| *w = true);
|
||||
let result = api.register(&credentials).await;
|
||||
set_wait_for_response.update(|w| *w = false);
|
||||
match result {
|
||||
Ok(res) => {
|
||||
set_register_response.update(|v| *v = Some(res));
|
||||
set_register_error.update(|e| *e = None);
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = match err {
|
||||
api::Error::Fetch(js_err) => {
|
||||
format!("{js_err:?}")
|
||||
}
|
||||
api::Error::Api(err) => err.message,
|
||||
};
|
||||
log::warn!(
|
||||
"Unable to register new account for {}: {msg}",
|
||||
credentials.email
|
||||
);
|
||||
set_register_error.update(|e| *e = Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let disabled = Signal::derive(cx, move || wait_for_response.get());
|
||||
|
||||
view! { cx,
|
||||
<Show
|
||||
when = move || register_response.get().is_some()
|
||||
fallback = move |_| view!{ cx,
|
||||
<CredentialsForm
|
||||
title = "Please enter the desired credentials"
|
||||
action_label = "Register"
|
||||
action = register_action
|
||||
error = register_error.into()
|
||||
disabled
|
||||
/>
|
||||
<p>"Your already have an account?"</p>
|
||||
<A href=Page::Login.path()>"Login"</A>
|
||||
}
|
||||
>
|
||||
<p>"You have successfully registered."</p>
|
||||
<p>
|
||||
"You can now "
|
||||
<A href=Page::Login.path()>"login"</A>
|
||||
" with your new account."
|
||||
</p>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
18
examples/login_with_token_csr_only/server/Cargo.toml
Normal file
18
examples/login_with_token_csr_only/server/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
api-boundary = "*"
|
||||
axum = { version = "0.6", features = ["headers"] }
|
||||
env_logger = "0.10"
|
||||
log = "0.4"
|
||||
mailparse = "0.14"
|
||||
pwhash = "1.0"
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.25", features = ["macros", "rt-multi-thread"] }
|
||||
tower-http = { version = "0.3", features = ["cors"] }
|
||||
uuid = { version = "1.3", features = ["v4"] }
|
||||
114
examples/login_with_token_csr_only/server/src/adapters.rs
Normal file
114
examples/login_with_token_csr_only/server/src/adapters.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use crate::{application::*, Error};
|
||||
use api_boundary as json;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
impl From<InvalidEmailAddress> for json::Error {
|
||||
fn from(_: InvalidEmailAddress) -> Self {
|
||||
Self {
|
||||
message: "Invalid email address".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InvalidPassword> for json::Error {
|
||||
fn from(err: InvalidPassword) -> Self {
|
||||
let InvalidPassword::TooShort(min_len) = err;
|
||||
Self {
|
||||
message: format!("Invalid password (min. length = {min_len})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateUserError> for json::Error {
|
||||
fn from(err: CreateUserError) -> Self {
|
||||
let message = match err {
|
||||
CreateUserError::UserExists => "User already exits".to_string(),
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoginError> for json::Error {
|
||||
fn from(err: LoginError) -> Self {
|
||||
let message = match err {
|
||||
LoginError::InvalidEmailOrPassword => {
|
||||
"Invalid email or password".to_string()
|
||||
}
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LogoutError> for json::Error {
|
||||
fn from(err: LogoutError) -> Self {
|
||||
let message = match err {
|
||||
LogoutError::NotLoggedIn => "No user is logged in".to_string(),
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for json::Error {
|
||||
fn from(err: AuthError) -> Self {
|
||||
let message = match err {
|
||||
AuthError::NotAuthorized => "Not authorized".to_string(),
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CredentialParsingError> for json::Error {
|
||||
fn from(err: CredentialParsingError) -> Self {
|
||||
match err {
|
||||
CredentialParsingError::EmailAddress(err) => err.into(),
|
||||
CredentialParsingError::Password(err) => err.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CredentialParsingError {
|
||||
#[error(transparent)]
|
||||
EmailAddress(#[from] InvalidEmailAddress),
|
||||
#[error(transparent)]
|
||||
Password(#[from] InvalidPassword),
|
||||
}
|
||||
|
||||
impl TryFrom<json::Credentials> for Credentials {
|
||||
type Error = CredentialParsingError;
|
||||
fn try_from(
|
||||
json::Credentials { email, password }: json::Credentials,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let email: EmailAddress = email.parse()?;
|
||||
let password = Password::try_from(password)?;
|
||||
Ok(Self { email, password })
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
let (code, value) = match self {
|
||||
Self::Logout(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::Login(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::Credentials(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::CreateUser(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::Auth(err) => {
|
||||
(StatusCode::UNAUTHORIZED, json::Error::from(err))
|
||||
}
|
||||
};
|
||||
(code, Json(value)).into_response()
|
||||
}
|
||||
}
|
||||
159
examples/login_with_token_csr_only/server/src/application.rs
Normal file
159
examples/login_with_token_csr_only/server/src/application.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use mailparse::addrparse;
|
||||
use pwhash::bcrypt;
|
||||
use std::{collections::HashMap, str::FromStr, sync::RwLock};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
users: RwLock<HashMap<EmailAddress, Password>>,
|
||||
tokens: RwLock<HashMap<Uuid, EmailAddress>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn create_user(
|
||||
&self,
|
||||
credentials: Credentials,
|
||||
) -> Result<(), CreateUserError> {
|
||||
let Credentials { email, password } = credentials;
|
||||
let user_exists = self.users.read().unwrap().get(&email).is_some();
|
||||
if user_exists {
|
||||
return Err(CreateUserError::UserExists);
|
||||
}
|
||||
self.users.write().unwrap().insert(email, password);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn login(
|
||||
&self,
|
||||
email: EmailAddress,
|
||||
password: &str,
|
||||
) -> Result<Uuid, LoginError> {
|
||||
let valid_credentials = self
|
||||
.users
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&email)
|
||||
.map(|hashed_password| hashed_password.verify(password))
|
||||
.unwrap_or(false);
|
||||
if !valid_credentials {
|
||||
Err(LoginError::InvalidEmailOrPassword)
|
||||
} else {
|
||||
let token = Uuid::new_v4();
|
||||
self.tokens.write().unwrap().insert(token, email);
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logout(&self, token: &str) -> Result<(), LogoutError> {
|
||||
let token = token
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| LogoutError::NotLoggedIn)?;
|
||||
self.tokens.write().unwrap().remove(&token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn authorize_user(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<CurrentUser, AuthError> {
|
||||
token
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| AuthError::NotAuthorized)
|
||||
.and_then(|token| {
|
||||
self.tokens
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&token)
|
||||
.cloned()
|
||||
.map(|email| CurrentUser { email, token })
|
||||
.ok_or(AuthError::NotAuthorized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CreateUserError {
|
||||
#[error("The user already exists")]
|
||||
UserExists,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoginError {
|
||||
#[error("Invalid email or password")]
|
||||
InvalidEmailOrPassword,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LogoutError {
|
||||
#[error("You are not logged in")]
|
||||
NotLoggedIn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("You are not authorized")]
|
||||
NotAuthorized,
|
||||
}
|
||||
|
||||
pub struct Credentials {
|
||||
pub email: EmailAddress,
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||
pub struct EmailAddress(String);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("The given email address is invalid")]
|
||||
pub struct InvalidEmailAddress;
|
||||
|
||||
impl FromStr for EmailAddress {
|
||||
type Err = InvalidEmailAddress;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
addrparse(s)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.extract_single_info())
|
||||
.map(|single_info| Self(single_info.addr))
|
||||
.ok_or(InvalidEmailAddress)
|
||||
}
|
||||
}
|
||||
|
||||
impl EmailAddress {
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CurrentUser {
|
||||
pub email: EmailAddress,
|
||||
pub token: Uuid,
|
||||
}
|
||||
|
||||
const MIN_PASSWORD_LEN: usize = 3;
|
||||
|
||||
pub struct Password(String);
|
||||
|
||||
impl Password {
|
||||
pub fn verify(&self, password: &str) -> bool {
|
||||
bcrypt::verify(password, &self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum InvalidPassword {
|
||||
#[error("Password is too short (min. length is {0})")]
|
||||
TooShort(usize),
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Password {
|
||||
type Error = InvalidPassword;
|
||||
fn try_from(p: String) -> Result<Self, Self::Error> {
|
||||
if p.len() < MIN_PASSWORD_LEN {
|
||||
return Err(InvalidPassword::TooShort(MIN_PASSWORD_LEN));
|
||||
}
|
||||
let hashed = bcrypt::hash(&p).unwrap();
|
||||
Ok(Self(hashed))
|
||||
}
|
||||
}
|
||||
113
examples/login_with_token_csr_only/server/src/main.rs
Normal file
113
examples/login_with_token_csr_only/server/src/main.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use std::{env, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::{State, TypedHeader},
|
||||
headers::{authorization::Bearer, Authorization},
|
||||
http::Method,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use api_boundary as json;
|
||||
|
||||
mod adapters;
|
||||
mod application;
|
||||
|
||||
use self::application::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
if let Err(err) = env::var("RUST_LOG") {
|
||||
match err {
|
||||
env::VarError::NotPresent => {
|
||||
env::set_var("RUST_LOG", "debug");
|
||||
}
|
||||
env::VarError::NotUnicode(_) => {
|
||||
return Err(anyhow::anyhow!("The value of 'RUST_LOG' does not contain valid unicode data."));
|
||||
}
|
||||
}
|
||||
}
|
||||
env_logger::init();
|
||||
|
||||
let shared_state = Arc::new(AppState::default());
|
||||
|
||||
let cors_layer = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_origin(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/users", post(create_user))
|
||||
.route("/users", get(get_user_info))
|
||||
.route_layer(cors_layer)
|
||||
.with_state(shared_state);
|
||||
|
||||
let addr = "0.0.0.0:3000".parse().unwrap();
|
||||
log::info!("Listen on {addr}");
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<Json<T>, Error>;
|
||||
|
||||
/// API error
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[non_exhaustive]
|
||||
enum Error {
|
||||
#[error(transparent)]
|
||||
CreateUser(#[from] CreateUserError),
|
||||
#[error(transparent)]
|
||||
Login(#[from] LoginError),
|
||||
#[error(transparent)]
|
||||
Logout(#[from] LogoutError),
|
||||
#[error(transparent)]
|
||||
Auth(#[from] AuthError),
|
||||
#[error(transparent)]
|
||||
Credentials(#[from] adapters::CredentialParsingError),
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(credentials): Json<json::Credentials>,
|
||||
) -> Result<()> {
|
||||
let credentials = Credentials::try_from(credentials)?;
|
||||
state.create_user(credentials)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(credentials): Json<json::Credentials>,
|
||||
) -> Result<json::ApiToken> {
|
||||
let json::Credentials { email, password } = credentials;
|
||||
log::debug!("{email} tries to login");
|
||||
let email = email.parse().map_err(|_|
|
||||
// Here we don't want to leak detailed info.
|
||||
LoginError::InvalidEmailOrPassword)?;
|
||||
let token = state.login(email, &password).map(|s| s.to_string())?;
|
||||
Ok(Json(json::ApiToken { token }))
|
||||
}
|
||||
|
||||
async fn logout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
|
||||
) -> Result<()> {
|
||||
state.logout(auth.token())?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
async fn get_user_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
|
||||
) -> Result<json::UserInfo> {
|
||||
let user = state.authorize_user(auth.token())?;
|
||||
let CurrentUser { email, .. } = user;
|
||||
Ok(Json(json::UserInfo {
|
||||
email: email.into_string(),
|
||||
}))
|
||||
}
|
||||
@@ -8,4 +8,4 @@ leptos = { path = "../../leptos" }
|
||||
console_log = "0.2"
|
||||
log = "0.4"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
|
||||
web-sys = "0.3"
|
||||
|
||||
@@ -7,7 +7,8 @@ use web_sys::MouseEvent;
|
||||
// for the child component to write into and the parent to read
|
||||
// 2) <ButtonB/>: passing a closure as one of the child component props, for
|
||||
// the child component to call
|
||||
// 4) <ButtonC/>: providing a context that is used in the component (rather than prop drilling)
|
||||
// 3) <ButtonC/>: adding an `on:` event listener to a component
|
||||
// 4) <ButtonD/>: providing a context that is used in the component (rather than prop drilling)
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
struct SmallcapsContext(WriteSignal<bool>);
|
||||
@@ -17,6 +18,7 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
// just some signals to toggle three classes on our <p>
|
||||
let (red, set_red) = create_signal(cx, false);
|
||||
let (right, set_right) = create_signal(cx, false);
|
||||
let (italics, set_italics) = create_signal(cx, false);
|
||||
let (smallcaps, set_smallcaps) = create_signal(cx, false);
|
||||
|
||||
// the newtype pattern isn't *necessary* here but is a good practice
|
||||
@@ -31,6 +33,7 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
// class: attributes take F: Fn() => bool, and these signals all implement Fn()
|
||||
class:red=red
|
||||
class:right=right
|
||||
class:italics=italics
|
||||
class:smallcaps=smallcaps
|
||||
>
|
||||
"Lorem ipsum sit dolor amet."
|
||||
@@ -42,8 +45,13 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
// Button B: pass a closure
|
||||
<ButtonB on_click=move |_| set_right.update(|value| *value = !*value)/>
|
||||
|
||||
// Button B: use a regular event listener
|
||||
// setting an event listener on a component like this applies it
|
||||
// to each of the top-level elements the component returns
|
||||
<ButtonC on:click=move |_| set_italics.update(|value| *value = !*value)/>
|
||||
|
||||
// Button D gets its setter from context rather than props
|
||||
<ButtonC/>
|
||||
<ButtonD/>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -53,7 +61,7 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
pub fn ButtonA(
|
||||
cx: Scope,
|
||||
/// Signal that will be toggled when the button is clicked.
|
||||
setter: WriteSignal<bool>
|
||||
setter: WriteSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
cx,
|
||||
@@ -70,7 +78,7 @@ pub fn ButtonA(
|
||||
pub fn ButtonB<F>(
|
||||
cx: Scope,
|
||||
/// Callback that will be invoked when the button is clicked.
|
||||
on_click: F
|
||||
on_click: F,
|
||||
) -> impl IntoView
|
||||
where
|
||||
F: Fn(MouseEvent) + 'static,
|
||||
@@ -97,10 +105,22 @@ where
|
||||
// if Rust ever had named function arguments we could drop this requirement
|
||||
}
|
||||
|
||||
/// Button C is a dummy: it renders a button but doesn't handle
|
||||
/// its click. Instead, the parent component adds an event listener.
|
||||
#[component]
|
||||
pub fn ButtonC(cx: Scope) -> impl IntoView {
|
||||
view! {
|
||||
cx,
|
||||
<button>
|
||||
"Toggle Italics"
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
/// Button D is very similar to Button A, but instead of passing the setter as a prop
|
||||
/// we get it from the context
|
||||
#[component]
|
||||
pub fn ButtonC(cx: Scope) -> impl IntoView {
|
||||
pub fn ButtonD(cx: Scope) -> impl IntoView {
|
||||
let setter = use_context::<SmallcapsContext>(cx).unwrap().0;
|
||||
|
||||
view! {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
mod api;
|
||||
|
||||
use crate::api::*;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use crate::api::{get_contact, get_contacts};
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
struct ExampleContext(i32);
|
||||
|
||||
#[component]
|
||||
pub fn RouterExample(cx: Scope) -> impl IntoView {
|
||||
log::debug!("rendering <RouterExample/>");
|
||||
|
||||
// contexts are passed down through the route tree
|
||||
provide_context(cx, ExampleContext(0));
|
||||
|
||||
view! { cx,
|
||||
<Router>
|
||||
<nav>
|
||||
@@ -59,10 +63,18 @@ pub fn RouterExample(cx: Scope) -> impl IntoView {
|
||||
pub fn ContactList(cx: Scope) -> impl IntoView {
|
||||
log::debug!("rendering <ContactList/>");
|
||||
|
||||
// contexts are passed down through the route tree
|
||||
provide_context(cx, ExampleContext(42));
|
||||
|
||||
on_cleanup(cx, || {
|
||||
log!("cleaning up <ContactList/>");
|
||||
});
|
||||
|
||||
let location = use_location(cx);
|
||||
let contacts = create_resource(cx, move || location.search.get(), get_contacts);
|
||||
let contacts =
|
||||
create_resource(cx, move || location.search.get(), get_contacts);
|
||||
let contacts = move || {
|
||||
contacts.read().map(|contacts| {
|
||||
contacts.read(cx).map(|contacts| {
|
||||
// this data doesn't change frequently so we can use .map().collect() instead of a keyed <For/>
|
||||
contacts
|
||||
.into_iter()
|
||||
@@ -95,6 +107,15 @@ pub struct ContactParams {
|
||||
pub fn Contact(cx: Scope) -> impl IntoView {
|
||||
log::debug!("rendering <Contact/>");
|
||||
|
||||
log::debug!(
|
||||
"ExampleContext should be Some(42). It is {:?}",
|
||||
use_context::<ExampleContext>(cx)
|
||||
);
|
||||
|
||||
on_cleanup(cx, || {
|
||||
log!("cleaning up <Contact/>");
|
||||
});
|
||||
|
||||
let params = use_params::<ContactParams>(cx);
|
||||
let contact = create_resource(
|
||||
cx,
|
||||
@@ -106,12 +127,15 @@ pub fn Contact(cx: Scope) -> impl IntoView {
|
||||
get_contact,
|
||||
);
|
||||
|
||||
let contact_display = move || match contact.read() {
|
||||
let contact_display = move || match contact.read(cx) {
|
||||
// None => loading, but will be caught by Suspense fallback
|
||||
// I'm only doing this explicitly for the example
|
||||
None => None,
|
||||
// Some(None) => has loaded and found no contact
|
||||
Some(None) => Some(view! { cx, <p>"No contact with this ID was found."</p> }.into_any()),
|
||||
Some(None) => Some(
|
||||
view! { cx, <p>"No contact with this ID was found."</p> }
|
||||
.into_any(),
|
||||
),
|
||||
// Some(Some) => has loaded and found a contact
|
||||
Some(Some(contact)) => Some(
|
||||
view! { cx,
|
||||
@@ -136,6 +160,16 @@ pub fn Contact(cx: Scope) -> impl IntoView {
|
||||
#[component]
|
||||
pub fn About(cx: Scope) -> impl IntoView {
|
||||
log::debug!("rendering <About/>");
|
||||
|
||||
on_cleanup(cx, || {
|
||||
log!("cleaning up <About/>");
|
||||
});
|
||||
|
||||
log::debug!(
|
||||
"ExampleContext should be Some(0). It is {:?}",
|
||||
use_context::<ExampleContext>(cx)
|
||||
);
|
||||
|
||||
// use_navigate allows you to navigate programmatically by calling a function
|
||||
let navigate = use_navigate(cx);
|
||||
|
||||
@@ -157,6 +191,11 @@ pub fn About(cx: Scope) -> impl IntoView {
|
||||
#[component]
|
||||
pub fn Settings(cx: Scope) -> impl IntoView {
|
||||
log::debug!("rendering <Settings/>");
|
||||
|
||||
on_cleanup(cx, || {
|
||||
log!("cleaning up <Settings/>");
|
||||
});
|
||||
|
||||
view! { cx,
|
||||
<>
|
||||
<h1>"Settings"</h1>
|
||||
|
||||
13
examples/ssr_modes/.gitignore
vendored
Normal file
13
examples/ssr_modes/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
pkg
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# node e2e test tools and outputs
|
||||
node_modules/
|
||||
test-results/
|
||||
end2end/playwright-report/
|
||||
playwright/.cache/
|
||||
88
examples/ssr_modes/Cargo.toml
Normal file
88
examples/ssr_modes/Cargo.toml
Normal file
@@ -0,0 +1,88 @@
|
||||
[package]
|
||||
name = "ssr_modes"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
actix-files = { version = "0.6", optional = true }
|
||||
actix-web = { version = "4", optional = true, features = ["macros"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "0.2"
|
||||
cfg-if = "1"
|
||||
lazy_static = "1"
|
||||
leptos = { path = "../../leptos", default-features = false, features = [
|
||||
"serde",
|
||||
] }
|
||||
leptos_meta = { path = "../../meta", default-features = false }
|
||||
leptos_actix = { path = "../../integrations/actix", default-features = false, optional = true }
|
||||
leptos_router = { path = "../../router", default-features = false }
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
simple_logger = "4"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:actix-files",
|
||||
"dep:actix-web",
|
||||
"dep:leptos_actix",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "ssr_modes"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "style/main.scss"
|
||||
# Assets source dir. All files found here will be copied and synchronized to site-root.
|
||||
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
|
||||
#
|
||||
# Optional. Env: LEPTOS_ASSETS_DIR.
|
||||
assets-dir = "assets"
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
# [Windows] for non-WSL use "npx.cmd playwright test"
|
||||
# This binary name can be checked in Powershell with Get-Command npx
|
||||
end2end-cmd = "npx playwright test"
|
||||
end2end-dir = "end2end"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
21
examples/ssr_modes/LICENSE
Normal file
21
examples/ssr_modes/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 henrik
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
54
examples/ssr_modes/README.md
Normal file
54
examples/ssr_modes/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Server-Side Rendering Modes
|
||||
|
||||
This example shows the different "rendering modes" that can be used while server-side
|
||||
rendering an application:
|
||||
1. **Synchronous**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the client, replacing `fallback` once they're loaded.
|
||||
- *Pros*: App shell appears very quickly: great TTFB (time to first byte).
|
||||
- *Cons*: Resources load relatively slowly; you need to wait for JS + Wasm to load before even making a request.
|
||||
2. **Out-of-order streaming**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the **server**, streaming it down to the client as it resolves, and streaming down HTML for `Suspense` nodes.
|
||||
- *Pros*: Combines the best of **synchronous** and **`async`**, with a very fast shell and resources that begin loading on the server.
|
||||
- *Cons*: Requires JS for suspended fragments to appear in correct order. Weaker meta tag support when it depends on data that's under suspense (has already streamed down `<head>`)
|
||||
3. **In-order streaming**: Walk through the tree, returning HTML synchronously as in synchronous rendering and out-of-order streaming until you hit a `Suspense`. At that point, wait for all its data to load, then render it, then the rest of the tree.
|
||||
- *Pros*: Does not require JS for HTML to appear in correct order.
|
||||
- *Cons*: Loads the shell more slowly than out-of-order streaming or synchronous rendering because it needs to pause at every `Suspense`. Cannot begin hydration until the entire page has loaded, so earlier pieces
|
||||
of the page will not be interactive until the suspended chunks have loaded.
|
||||
4. **`async`**: Load all resources on the server. Wait until all data are loaded, and render HTML in one sweep.
|
||||
- *Pros*: Better handling for meta tags (because you know async data even before you render the `<head>`). Faster complete load than **synchronous** because async resources begin loading on server.
|
||||
- *Cons*: Slower load time/TTFB: you need to wait for all async resources to load before displaying anything on the client.
|
||||
|
||||
## Server Side Rendering with `cargo-leptos`
|
||||
`cargo-leptos` is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
|
||||
|
||||
1. Install cargo-leptos
|
||||
```bash
|
||||
cargo install --locked cargo-leptos
|
||||
```
|
||||
2. Build the site in watch mode, recompiling on file changes
|
||||
```bash
|
||||
cargo leptos watch
|
||||
```
|
||||
|
||||
Open browser on [http://localhost:3000/](http://localhost:3000/)
|
||||
|
||||
3. When ready to deploy, run
|
||||
```bash
|
||||
cargo leptos build --release
|
||||
```
|
||||
|
||||
## Server Side Rendering without cargo-leptos
|
||||
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
|
||||
|
||||
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
|
||||
1. Install wasm-pack
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
```
|
||||
2. Build the Webassembly used to hydrate the HTML from the server
|
||||
```bash
|
||||
wasm-pack build --target=web --debug --no-default-features --features=hydrate
|
||||
```
|
||||
3. Run the server to serve the Webassembly, JS, and HTML
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
|
||||
BIN
examples/ssr_modes/assets/favicon.ico
Normal file
BIN
examples/ssr_modes/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
184
examples/ssr_modes/src/app.rs
Normal file
184
examples/ssr_modes/src/app.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use lazy_static::lazy_static;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context(cx);
|
||||
|
||||
view! { cx,
|
||||
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
|
||||
<Title text="Welcome to Leptos"/>
|
||||
|
||||
<Router>
|
||||
<main>
|
||||
<Routes>
|
||||
// We’ll load the home page with out-of-order streaming and <Suspense/>
|
||||
<Route path="" view=|cx| view! { cx, <HomePage/> }/>
|
||||
|
||||
// We'll load the posts with async rendering, so they can set
|
||||
// the title and metadata *after* loading the data
|
||||
<Route
|
||||
path="/post/:id"
|
||||
view=|cx| view! { cx, <Post/> }
|
||||
ssr=SsrMode::Async
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn HomePage(cx: Scope) -> impl IntoView {
|
||||
// load the posts
|
||||
let posts =
|
||||
create_resource(cx, || (), |_| async { list_post_metadata().await });
|
||||
let posts_view = move || {
|
||||
posts.with(cx, |posts| posts
|
||||
.clone()
|
||||
.map(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! { cx, <li><a href=format!("/post/{}", post.id)>{&post.title}</a></li>})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Great Blog"</h1>
|
||||
<Suspense fallback=move || view! { cx, <p>"Loading posts..."</p> }>
|
||||
<ul>{posts_view}</ul>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Params, Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PostParams {
|
||||
id: usize,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Post(cx: Scope) -> impl IntoView {
|
||||
let query = use_params::<PostParams>(cx);
|
||||
let id = move || {
|
||||
query.with(|q| {
|
||||
q.as_ref().map(|q| q.id).map_err(|_| PostError::InvalidId)
|
||||
})
|
||||
};
|
||||
let post = create_resource(cx, id, |id| async move {
|
||||
match id {
|
||||
Err(e) => Err(e),
|
||||
Ok(id) => get_post(id)
|
||||
.await
|
||||
.map(|data| data.ok_or(PostError::PostNotFound))
|
||||
.map_err(|_| PostError::ServerError)
|
||||
.flatten(),
|
||||
}
|
||||
});
|
||||
|
||||
let post_view = move || {
|
||||
post.with(cx, |post| {
|
||||
post.clone().map(|post| {
|
||||
view! { cx,
|
||||
// render content
|
||||
<h1>{&post.title}</h1>
|
||||
<p>{&post.content}</p>
|
||||
|
||||
// since we're using async rendering for this page,
|
||||
// this metadata should be included in the actual HTML <head>
|
||||
// when it's first served
|
||||
<Title text=post.title/>
|
||||
<Meta name="description" content=post.content/>
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<Suspense fallback=move || view! { cx, <p>"Loading post..."</p> }>
|
||||
<ErrorBoundary fallback=|cx, errors| {
|
||||
view! { cx,
|
||||
<div class="error">
|
||||
<h1>"Something went wrong."</h1>
|
||||
<ul>
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, error)| view! { cx, <li>{error.to_string()} </li> })
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}>
|
||||
{post_view}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
// Dummy API
|
||||
lazy_static! {
|
||||
static ref POSTS: Vec<Post> = vec![
|
||||
Post {
|
||||
id: 0,
|
||||
title: "My first post".to_string(),
|
||||
content: "This is my first post".to_string(),
|
||||
},
|
||||
Post {
|
||||
id: 1,
|
||||
title: "My second post".to_string(),
|
||||
content: "This is my second post".to_string(),
|
||||
},
|
||||
Post {
|
||||
id: 2,
|
||||
title: "My third post".to_string(),
|
||||
content: "This is my third post".to_string(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PostError {
|
||||
#[error("Invalid post ID.")]
|
||||
InvalidId,
|
||||
#[error("Post not found.")]
|
||||
PostNotFound,
|
||||
#[error("Server error.")]
|
||||
ServerError,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
id: usize,
|
||||
title: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PostMetadata {
|
||||
id: usize,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[server(ListPostMetadata, "/api")]
|
||||
pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(POSTS
|
||||
.iter()
|
||||
.map(|data| PostMetadata {
|
||||
id: data.id,
|
||||
title: data.title.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[server(GetPost, "/api")]
|
||||
pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(POSTS.iter().find(|post| post.id == id).cloned())
|
||||
}
|
||||
25
examples/ssr_modes/src/lib.rs
Normal file
25
examples/ssr_modes/src/lib.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
#![feature(result_flattening)]
|
||||
|
||||
pub mod app;
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "hydrate")] {
|
||||
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
use app::*;
|
||||
use leptos::*;
|
||||
|
||||
// initializes logging using the `log` crate
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(move |cx| {
|
||||
view! { cx, <App/> }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
42
examples/ssr_modes/src/main.rs
Normal file
42
examples/ssr_modes/src/main.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
use actix_files::Files;
|
||||
use actix_web::*;
|
||||
use leptos::*;
|
||||
use leptos_actix::{generate_route_list, LeptosRoutes};
|
||||
use ssr_modes::app::*;
|
||||
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
// Generate the list of routes in your Leptos App
|
||||
let routes = generate_route_list(|cx| view! { cx, <App/> });
|
||||
|
||||
GetPost::register();
|
||||
ListPostMetadata::register();
|
||||
|
||||
HttpServer::new(move || {
|
||||
let leptos_options = &conf.leptos_options;
|
||||
let site_root = &leptos_options.site_root;
|
||||
|
||||
App::new()
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(
|
||||
leptos_options.to_owned(),
|
||||
routes.to_owned(),
|
||||
|cx| view! { cx, <App/> },
|
||||
)
|
||||
.service(Files::new("/", site_root))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub fn main() {
|
||||
// no client-side main function
|
||||
// unless we want this to work with e.g., Trunk for pure client-side testing
|
||||
// see lib.rs for hydration function instead
|
||||
}
|
||||
3
examples/ssr_modes/style/main.scss
Normal file
3
examples/ssr_modes/style/main.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
13
examples/ssr_modes_axum/.gitignore
vendored
Normal file
13
examples/ssr_modes_axum/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
pkg
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# node e2e test tools and outputs
|
||||
node_modules/
|
||||
test-results/
|
||||
end2end/playwright-report/
|
||||
playwright/.cache/
|
||||
91
examples/ssr_modes_axum/Cargo.toml
Normal file
91
examples/ssr_modes_axum/Cargo.toml
Normal file
@@ -0,0 +1,91 @@
|
||||
[package]
|
||||
name = "ssr_modes_axum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "0.2"
|
||||
cfg-if = "1"
|
||||
lazy_static = "1"
|
||||
leptos = { path = "../../leptos", default-features = false, features = [
|
||||
"serde",
|
||||
] }
|
||||
leptos_meta = { path = "../../meta", default-features = false }
|
||||
leptos_axum = { path = "../../integrations/axum", default-features = false, optional = true }
|
||||
leptos_router = { path = "../../router", default-features = false }
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
simple_logger = "4"
|
||||
thiserror = "1"
|
||||
axum = { version = "0.6.1", optional = true }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.3.4", features = ["fs"], optional = true }
|
||||
tokio = { version = "1", features = ["time"], optional = true}
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "ssr_modes"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "style/main.scss"
|
||||
# Assets source dir. All files found here will be copied and synchronized to site-root.
|
||||
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
|
||||
#
|
||||
# Optional. Env: LEPTOS_ASSETS_DIR.
|
||||
assets-dir = "assets"
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
# [Windows] for non-WSL use "npx.cmd playwright test"
|
||||
# This binary name can be checked in Powershell with Get-Command npx
|
||||
end2end-cmd = "npx playwright test"
|
||||
end2end-dir = "end2end"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
21
examples/ssr_modes_axum/LICENSE
Normal file
21
examples/ssr_modes_axum/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 henrik
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
54
examples/ssr_modes_axum/README.md
Normal file
54
examples/ssr_modes_axum/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Server-Side Rendering Modes
|
||||
|
||||
This example shows the different "rendering modes" that can be used while server-side
|
||||
rendering an application:
|
||||
1. **Synchronous**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the client, replacing `fallback` once they're loaded.
|
||||
- *Pros*: App shell appears very quickly: great TTFB (time to first byte).
|
||||
- *Cons*: Resources load relatively slowly; you need to wait for JS + Wasm to load before even making a request.
|
||||
2. **Out-of-order streaming**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the **server**, streaming it down to the client as it resolves, and streaming down HTML for `Suspense` nodes.
|
||||
- *Pros*: Combines the best of **synchronous** and **`async`**, with a very fast shell and resources that begin loading on the server.
|
||||
- *Cons*: Requires JS for suspended fragments to appear in correct order. Weaker meta tag support when it depends on data that's under suspense (has already streamed down `<head>`)
|
||||
3. **In-order streaming**: Walk through the tree, returning HTML synchronously as in synchronous rendering and out-of-order streaming until you hit a `Suspense`. At that point, wait for all its data to load, then render it, then the rest of the tree.
|
||||
- *Pros*: Does not require JS for HTML to appear in correct order.
|
||||
- *Cons*: Loads the shell more slowly than out-of-order streaming or synchronous rendering because it needs to pause at every `Suspense`. Cannot begin hydration until the entire page has loaded, so earlier pieces
|
||||
of the page will not be interactive until the suspended chunks have loaded.
|
||||
4. **`async`**: Load all resources on the server. Wait until all data are loaded, and render HTML in one sweep.
|
||||
- *Pros*: Better handling for meta tags (because you know async data even before you render the `<head>`). Faster complete load than **synchronous** because async resources begin loading on server.
|
||||
- *Cons*: Slower load time/TTFB: you need to wait for all async resources to load before displaying anything on the client.
|
||||
|
||||
## Server Side Rendering with `cargo-leptos`
|
||||
`cargo-leptos` is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
|
||||
|
||||
1. Install cargo-leptos
|
||||
```bash
|
||||
cargo install --locked cargo-leptos
|
||||
```
|
||||
2. Build the site in watch mode, recompiling on file changes
|
||||
```bash
|
||||
cargo leptos watch
|
||||
```
|
||||
|
||||
Open browser on [http://localhost:3000/](http://localhost:3000/)
|
||||
|
||||
3. When ready to deploy, run
|
||||
```bash
|
||||
cargo leptos build --release
|
||||
```
|
||||
|
||||
## Server Side Rendering without cargo-leptos
|
||||
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
|
||||
|
||||
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
|
||||
1. Install wasm-pack
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
```
|
||||
2. Build the Webassembly used to hydrate the HTML from the server
|
||||
```bash
|
||||
wasm-pack build --target=web --debug --no-default-features --features=hydrate
|
||||
```
|
||||
3. Run the server to serve the Webassembly, JS, and HTML
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
|
||||
BIN
examples/ssr_modes_axum/assets/favicon.ico
Normal file
BIN
examples/ssr_modes_axum/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
189
examples/ssr_modes_axum/src/app.rs
Normal file
189
examples/ssr_modes_axum/src/app.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use lazy_static::lazy_static;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context(cx);
|
||||
|
||||
view! { cx,
|
||||
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
|
||||
<Title text="Welcome to Leptos"/>
|
||||
|
||||
<Router>
|
||||
<main>
|
||||
<Routes>
|
||||
// We’ll load the home page with out-of-order streaming and <Suspense/>
|
||||
<Route path="" view=|cx| view! { cx, <HomePage/> }/>
|
||||
|
||||
// We'll load the posts with async rendering, so they can set
|
||||
// the title and metadata *after* loading the data
|
||||
<Route
|
||||
path="/post/:id"
|
||||
view=|cx| view! { cx, <Post/> }
|
||||
ssr=SsrMode::Async
|
||||
/>
|
||||
<Route
|
||||
path="/post_in_order/:id"
|
||||
view=|cx| view! { cx, <Post/> }
|
||||
ssr=SsrMode::InOrder
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn HomePage(cx: Scope) -> impl IntoView {
|
||||
// load the posts
|
||||
let posts =
|
||||
create_resource(cx, || (), |_| async { list_post_metadata().await });
|
||||
let posts_view = move || {
|
||||
posts.with(cx, |posts| posts
|
||||
.clone()
|
||||
.map(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! { cx, <li><a href=format!("/post/{}", post.id)>{&post.title}</a> "|" <a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a></li>})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Great Blog"</h1>
|
||||
<Suspense fallback=move || view! { cx, <p>"Loading posts..."</p> }>
|
||||
<ul>{posts_view}</ul>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Params, Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PostParams {
|
||||
id: usize,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Post(cx: Scope) -> impl IntoView {
|
||||
let query = use_params::<PostParams>(cx);
|
||||
let id = move || {
|
||||
query.with(|q| {
|
||||
q.as_ref().map(|q| q.id).map_err(|_| PostError::InvalidId)
|
||||
})
|
||||
};
|
||||
let post = create_resource(cx, id, |id| async move {
|
||||
match id {
|
||||
Err(e) => Err(e),
|
||||
Ok(id) => get_post(id)
|
||||
.await
|
||||
.map(|data| data.ok_or(PostError::PostNotFound))
|
||||
.map_err(|_| PostError::ServerError)
|
||||
.flatten(),
|
||||
}
|
||||
});
|
||||
|
||||
let post_view = move || {
|
||||
post.with(cx, |post| {
|
||||
post.clone().map(|post| {
|
||||
view! { cx,
|
||||
// render content
|
||||
<h1>{&post.title}</h1>
|
||||
<p>{&post.content}</p>
|
||||
|
||||
// since we're using async rendering for this page,
|
||||
// this metadata should be included in the actual HTML <head>
|
||||
// when it's first served
|
||||
<Title text=post.title/>
|
||||
<Meta name="description" content=post.content/>
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<Suspense fallback=move || view! { cx, <p>"Loading post..."</p> }>
|
||||
<ErrorBoundary fallback=|cx, errors| {
|
||||
view! { cx,
|
||||
<div class="error">
|
||||
<h1>"Something went wrong."</h1>
|
||||
<ul>
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, error)| view! { cx, <li>{error.to_string()} </li> })
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}>
|
||||
{post_view}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
// Dummy API
|
||||
lazy_static! {
|
||||
static ref POSTS: Vec<Post> = vec![
|
||||
Post {
|
||||
id: 0,
|
||||
title: "My first post".to_string(),
|
||||
content: "This is my first post".to_string(),
|
||||
},
|
||||
Post {
|
||||
id: 1,
|
||||
title: "My second post".to_string(),
|
||||
content: "This is my second post".to_string(),
|
||||
},
|
||||
Post {
|
||||
id: 2,
|
||||
title: "My third post".to_string(),
|
||||
content: "This is my third post".to_string(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PostError {
|
||||
#[error("Invalid post ID.")]
|
||||
InvalidId,
|
||||
#[error("Post not found.")]
|
||||
PostNotFound,
|
||||
#[error("Server error.")]
|
||||
ServerError,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
id: usize,
|
||||
title: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PostMetadata {
|
||||
id: usize,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[server(ListPostMetadata, "/api")]
|
||||
pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(POSTS
|
||||
.iter()
|
||||
.map(|data| PostMetadata {
|
||||
id: data.id,
|
||||
title: data.title.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[server(GetPost, "/api")]
|
||||
pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(POSTS.iter().find(|post| post.id == id).cloned())
|
||||
}
|
||||
45
examples/ssr_modes_axum/src/fallback.rs
Normal file
45
examples/ssr_modes_axum/src/fallback.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! { if #[cfg(feature = "ssr")] {
|
||||
use axum::{
|
||||
body::{boxed, Body, BoxBody},
|
||||
extract::Extension,
|
||||
response::IntoResponse,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
use std::sync::Arc;
|
||||
use leptos::{LeptosOptions, Errors, view};
|
||||
use crate::app::{App, AppProps};
|
||||
|
||||
pub async fn file_and_error_handler(uri: Uri, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<Body>) -> AxumResponse {
|
||||
let options = &*options;
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else{
|
||||
let handler = leptos_axum::render_app_to_stream(
|
||||
options.to_owned(),
|
||||
move |cx| view!{ cx, <App/> }
|
||||
);
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
|
||||
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root).oneshot(req).await {
|
||||
Ok(res) => Ok(res.map(boxed)),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {err}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}}
|
||||
26
examples/ssr_modes_axum/src/lib.rs
Normal file
26
examples/ssr_modes_axum/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
#![feature(result_flattening)]
|
||||
|
||||
pub mod app;
|
||||
pub mod fallback;
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "hydrate")] {
|
||||
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
use app::*;
|
||||
use leptos::*;
|
||||
|
||||
// initializes logging using the `log` crate
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(move |cx| {
|
||||
view! { cx, <App/> }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
40
examples/ssr_modes_axum/src/main.rs
Normal file
40
examples/ssr_modes_axum/src/main.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
#[tokio::main]
|
||||
async fn main(){
|
||||
use leptos::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use axum::{extract::{Extension, Path}, Router, routing::{get, post}};
|
||||
use std::sync::Arc;
|
||||
use ssr_modes_axum::fallback::file_and_error_handler;
|
||||
use ssr_modes_axum::app::*;
|
||||
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
let leptos_options = conf.leptos_options;
|
||||
// Generate the list of routes in your Leptos App
|
||||
let routes = generate_route_list(|cx| view! { cx, <App/> }).await;
|
||||
|
||||
GetPost::register();
|
||||
ListPostMetadata::register();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
|
||||
.leptos_routes(leptos_options.clone(), routes, |cx| view! { cx, <App/> })
|
||||
.fallback(file_and_error_handler)
|
||||
.layer(Extension(Arc::new(leptos_options)));
|
||||
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
log!("listening on http://{}", &addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub fn main() {
|
||||
// no client-side main function
|
||||
// unless we want this to work with e.g., Trunk for pure client-side testing
|
||||
// see lib.rs for hydration function instead
|
||||
}
|
||||
3
examples/ssr_modes_axum/style/main.scss
Normal file
3
examples/ssr_modes_axum/style/main.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Cargo Leptos</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- INJECT HEAD -->
|
||||
</head>
|
||||
<body>
|
||||
<!-- INJECT BODY -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,6 +29,7 @@ sqlx = { version = "0.6.2", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["ssr"]
|
||||
|
||||
@@ -140,7 +140,7 @@ pub fn Todos(cx: Scope) -> impl IntoView {
|
||||
{move || {
|
||||
let existing_todos = {
|
||||
move || {
|
||||
todos.read()
|
||||
todos.read(cx)
|
||||
.map(move |todos| match todos {
|
||||
Err(e) => {
|
||||
vec![view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_any()]
|
||||
|
||||
@@ -36,22 +36,26 @@ sqlx = { version = "0.6.2", features = [
|
||||
], optional = true }
|
||||
thiserror = "1.0.38"
|
||||
tracing = "0.1.37"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["csr"]
|
||||
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = ["dep:axum", "dep:tower", "dep:tower-http", "dep:tokio", "dep:sqlx", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "dep:leptos_axum"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"dep:sqlx",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = [
|
||||
"axum",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tokio",
|
||||
"sqlx",
|
||||
"leptos_axum",
|
||||
]
|
||||
denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::errors::TodoAppError;
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::Errors;
|
||||
use leptos::*;
|
||||
|
||||
use leptos::{Errors, *};
|
||||
#[cfg(feature = "ssr")]
|
||||
use leptos_axum::ResponseOptions;
|
||||
|
||||
@@ -23,14 +21,12 @@ pub fn ErrorTemplate(
|
||||
};
|
||||
|
||||
// Get Errors from Signal
|
||||
let errors = errors.get().0;
|
||||
|
||||
// Downcast lets us take a type that implements `std::error::Error`
|
||||
let errors: Vec<TodoAppError> = errors
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter_map(|(_k, v)| v.downcast_ref::<TodoAppError>().cloned())
|
||||
.filter_map(|(_, v)| v.downcast_ref::<TodoAppError>().cloned())
|
||||
.collect();
|
||||
println!("Errors: {errors:#?}");
|
||||
|
||||
// Only the response code for the first error is actually sent from the server
|
||||
// this may be customized by the specific application
|
||||
@@ -51,7 +47,7 @@ pub fn ErrorTemplate(
|
||||
// a unique key for each item as a reference
|
||||
key=|(index, _error)| *index
|
||||
// renders each item to a view
|
||||
view= move |error| {
|
||||
view= move |cx, error| {
|
||||
let error_string = error.1.to_string();
|
||||
let error_code= error.1.status_code();
|
||||
view! {
|
||||
|
||||
@@ -159,7 +159,7 @@ pub fn Todos(cx: Scope) -> impl IntoView {
|
||||
{move || {
|
||||
let existing_todos = {
|
||||
move || {
|
||||
todos.read()
|
||||
todos.read(cx)
|
||||
.map(move |todos| match todos {
|
||||
Err(e) => {
|
||||
vec![view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_any()]
|
||||
|
||||
99
examples/todo_app_sqlite_viz/Cargo.toml
Normal file
99
examples/todo_app_sqlite_viz/Cargo.toml
Normal file
@@ -0,0 +1,99 @@
|
||||
[package]
|
||||
name = "todo_app_sqlite_viz"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.66"
|
||||
console_log = "0.2.0"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
futures = "0.3.25"
|
||||
cfg-if = "1.0.0"
|
||||
leptos = { path = "../../leptos", default-features = false, features = [
|
||||
"serde",
|
||||
] }
|
||||
leptos_viz = { path = "../../integrations/viz", default-features = false, optional = true }
|
||||
leptos_meta = { path = "../../meta", default-features = false }
|
||||
leptos_router = { path = "../../router", default-features = false }
|
||||
leptos_reactive = { path = "../../leptos_reactive", default-features = false }
|
||||
log = "0.4.17"
|
||||
simple_logger = "4.0.0"
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
serde_json = "1.0.89"
|
||||
gloo-net = { version = "0.2.5", features = ["http"] }
|
||||
reqwest = { version = "0.11.13", features = ["json"] }
|
||||
viz = { version = "0.4.8", features = ["serve"], optional = true }
|
||||
tokio = { version = "1.25.0", features = ["full"], optional = true }
|
||||
http = { version = "0.2.8" }
|
||||
sqlx = { version = "0.6.2", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
thiserror = "1.0.38"
|
||||
tracing = "0.1.37"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["csr"]
|
||||
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:viz",
|
||||
"dep:tokio",
|
||||
"dep:sqlx",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_viz"
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["viz", "tokio", "sqlx", "leptos_viz"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "todo_app_sqlite_viz"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "./style.css"
|
||||
# [Optional] Files in the asset-dir will be copied to the site-root directory
|
||||
assets-dir = "public"
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
21
examples/todo_app_sqlite_viz/LICENSE
Normal file
21
examples/todo_app_sqlite_viz/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Greg Johnston
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
9
examples/todo_app_sqlite_viz/Makefile.toml
Normal file
9
examples/todo_app_sqlite_viz/Makefile.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[tasks.build]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
42
examples/todo_app_sqlite_viz/README.md
Normal file
42
examples/todo_app_sqlite_viz/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Leptos Todo App Sqlite with Viz
|
||||
|
||||
This example creates a basic todo app with a Viz backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server.
|
||||
|
||||
## Client Side Rendering
|
||||
This example cannot be built as a trunk standalone CSR-only app. Only the server may directly connect to the database.
|
||||
|
||||
## Server Side Rendering with cargo-leptos
|
||||
cargo-leptos is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
|
||||
|
||||
1. Install cargo-leptos
|
||||
```bash
|
||||
cargo install --locked cargo-leptos
|
||||
```
|
||||
2. Build the site in watch mode, recompiling on file changes
|
||||
```bash
|
||||
cargo leptos watch
|
||||
```
|
||||
|
||||
Open browser on [http://localhost:3000/](http://localhost:3000/)
|
||||
|
||||
3. When ready to deploy, run
|
||||
```bash
|
||||
cargo leptos build --release
|
||||
```
|
||||
|
||||
## Server Side Rendering without cargo-leptos
|
||||
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
|
||||
|
||||
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
|
||||
1. Install wasm-pack
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
```
|
||||
2. Build the Webassembly used to hydrate the HTML from the server
|
||||
```bash
|
||||
wasm-pack build --target=web --debug --no-default-features --features=hydrate
|
||||
```
|
||||
3. Run the server to serve the Webassembly, JS, and HTML
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
BIN
examples/todo_app_sqlite_viz/Todos.db
Normal file
BIN
examples/todo_app_sqlite_viz/Todos.db
Normal file
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS todos
|
||||
(
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
title VARCHAR,
|
||||
completed BOOLEAN
|
||||
);
|
||||
BIN
examples/todo_app_sqlite_viz/public/favicon.ico
Normal file
BIN
examples/todo_app_sqlite_viz/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
61
examples/todo_app_sqlite_viz/src/error_template.rs
Normal file
61
examples/todo_app_sqlite_viz/src/error_template.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use crate::errors::TodoAppError;
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::{Errors, *};
|
||||
#[cfg(feature = "ssr")]
|
||||
use leptos_viz::ResponseOptions;
|
||||
|
||||
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
|
||||
// here than just displaying them
|
||||
#[component]
|
||||
pub fn ErrorTemplate(
|
||||
cx: Scope,
|
||||
#[prop(optional)] outside_errors: Option<Errors>,
|
||||
#[prop(optional)] errors: Option<RwSignal<Errors>>,
|
||||
) -> impl IntoView {
|
||||
let errors = match outside_errors {
|
||||
Some(e) => create_rw_signal(cx, e),
|
||||
None => match errors {
|
||||
Some(e) => e,
|
||||
None => panic!("No Errors found and we expected errors!"),
|
||||
},
|
||||
};
|
||||
|
||||
// Get Errors from Signal
|
||||
// Downcast lets us take a type that implements `std::error::Error`
|
||||
let errors: Vec<TodoAppError> = errors
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter_map(|(_k, v)| v.downcast_ref::<TodoAppError>().cloned())
|
||||
.collect();
|
||||
|
||||
// Only the response code for the first error is actually sent from the server
|
||||
// this may be customized by the specific application
|
||||
cfg_if! {
|
||||
if #[cfg(feature="ssr")]{
|
||||
let response = use_context::<ResponseOptions>(cx);
|
||||
if let Some(response) = response{
|
||||
response.set_status(errors[0].status_code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view! {cx,
|
||||
<h1>"Errors"</h1>
|
||||
<For
|
||||
// a function that returns the items we're iterating over; a signal is fine
|
||||
each= move || {errors.clone().into_iter().enumerate()}
|
||||
// a unique key for each item as a reference
|
||||
key=|(index, _error)| *index
|
||||
// renders each item to a view
|
||||
view= move |cx, error| {
|
||||
let error_string = error.1.to_string();
|
||||
let error_code= error.1.status_code();
|
||||
view! {
|
||||
cx,
|
||||
<h2>{error_code.to_string()}</h2>
|
||||
<p>"Error: " {error_string}</p>
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
||||
21
examples/todo_app_sqlite_viz/src/errors.rs
Normal file
21
examples/todo_app_sqlite_viz/src/errors.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use http::status::StatusCode;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub enum TodoAppError {
|
||||
#[error("Not Found")]
|
||||
NotFound,
|
||||
#[error("Internal Server Error")]
|
||||
InternalServerError,
|
||||
}
|
||||
|
||||
impl TodoAppError {
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
TodoAppError::NotFound => StatusCode::NOT_FOUND,
|
||||
TodoAppError::InternalServerError => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
examples/todo_app_sqlite_viz/src/fallback.rs
Normal file
58
examples/todo_app_sqlite_viz/src/fallback.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::{
|
||||
error_template::{ErrorTemplate, ErrorTemplateProps},
|
||||
errors::TodoAppError,
|
||||
};
|
||||
use http::Uri;
|
||||
use leptos::{view, Errors, LeptosOptions};
|
||||
use std::sync::Arc;
|
||||
use viz::{
|
||||
handlers::serve, header::HeaderMap, types::RouteInfo, Body, Error, Handler,
|
||||
Request, RequestExt, Response, ResponseExt, Result,
|
||||
};
|
||||
|
||||
pub async fn file_and_error_handler(req: Request<Body>) -> Result<Response> {
|
||||
let uri = req.uri().clone();
|
||||
let headers = req.headers().clone();
|
||||
let route_info = req.route_info().clone();
|
||||
let options = &*req.state::<Arc<LeptosOptions>>().ok_or(
|
||||
Error::Responder(Response::text("missing state type LeptosOptions")),
|
||||
)?;
|
||||
let root = &options.site_root;
|
||||
let resp = get_static_file(uri, &root, headers, route_info).await?;
|
||||
let status = resp.status();
|
||||
|
||||
if status.is_success() || status.is_redirection() {
|
||||
Ok(resp)
|
||||
} else {
|
||||
let mut errors = Errors::default();
|
||||
errors.insert_with_default_key(TodoAppError::NotFound);
|
||||
let handler = leptos_viz::render_app_to_stream(
|
||||
options.to_owned(),
|
||||
move |cx| view! {cx, <ErrorTemplate outside_errors=errors.clone()/>},
|
||||
);
|
||||
handler(req).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(
|
||||
uri: Uri,
|
||||
root: &str,
|
||||
headers: HeaderMap,
|
||||
route_info: Arc<RouteInfo>,
|
||||
) -> Result<Response> {
|
||||
let mut req = Request::builder()
|
||||
.uri(uri.clone())
|
||||
.extension(route_info)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
*req.headers_mut() = headers;
|
||||
// This path is relative to the cargo root
|
||||
serve::Dir::new(root).call(req).await
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
25
examples/todo_app_sqlite_viz/src/lib.rs
Normal file
25
examples/todo_app_sqlite_viz/src/lib.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
pub mod error_template;
|
||||
pub mod errors;
|
||||
pub mod fallback;
|
||||
pub mod todo;
|
||||
|
||||
// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong.
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "hydrate")] {
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use crate::todo::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(|cx| {
|
||||
view! { cx, <TodoApp/> }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
80
examples/todo_app_sqlite_viz/src/main.rs
Normal file
80
examples/todo_app_sqlite_viz/src/main.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
// boilerplate to run in different modes
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::fallback::file_and_error_handler;
|
||||
use crate::todo::*;
|
||||
use leptos_viz::{generate_route_list, LeptosRoutes};
|
||||
use std::sync::Arc;
|
||||
use todo_app_sqlite_viz::*;
|
||||
use viz::{
|
||||
types::{State, StateError},
|
||||
Request, RequestExt, Response, Result, Router, ServiceMaker,
|
||||
};
|
||||
|
||||
//Define a handler to test extractor with state
|
||||
async fn custom_handler(req: Request) -> Result<Response> {
|
||||
let id = req.params::<String>()?;
|
||||
let options = &*req
|
||||
.state::<Arc<LeptosOptions>>()
|
||||
.ok_or(StateError::new::<LeptosOptions>())?;
|
||||
let handler = leptos_viz::render_app_to_stream_with_context(
|
||||
options.clone(),
|
||||
move |cx| {
|
||||
provide_context(cx, id.clone());
|
||||
},
|
||||
|cx| view! { cx, <TodoApp/> },
|
||||
);
|
||||
handler(req).await
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
simple_logger::init_with_level(log::Level::Debug)
|
||||
.expect("couldn't initialize logging");
|
||||
|
||||
let conn = db().await.expect("couldn't connect to DB");
|
||||
/* sqlx::migrate!()
|
||||
.run(&mut conn)
|
||||
.await
|
||||
.expect("could not run SQLx migrations"); */
|
||||
|
||||
crate::todo::register_server_functions();
|
||||
|
||||
// Setting this to None means we'll be using cargo-leptos and its env vars
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(|cx| view! { cx, <TodoApp/> }).await;
|
||||
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.post("/api/:fn_name*", leptos_viz::handle_server_fns)
|
||||
.get("/special/:id", custom_handler)
|
||||
.leptos_routes(
|
||||
leptos_options.clone(),
|
||||
routes,
|
||||
|cx| view! { cx, <TodoApp/> },
|
||||
)
|
||||
.get("/*", file_and_error_handler)
|
||||
.with(State(Arc::new(leptos_options)));
|
||||
|
||||
// run our app with hyper
|
||||
// `viz::Server` is a re-export of `hyper::Server`
|
||||
log!("listening on http://{}", &addr);
|
||||
viz::Server::bind(&addr)
|
||||
.serve(ServiceMaker::from(app))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// client-only stuff for Trunk
|
||||
else {
|
||||
pub fn main() {
|
||||
// This example cannot be built as a trunk standalone CSR-only app.
|
||||
// Only the server may directly connect to the database.
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user