In the previous example we've added auth to the todo PWA. In this one we'll make all the todos public and provide real-time updates to the clients about every change in them. We'll add a new dependency here - async-broadcast which provides a simple mechanism to share changes with multiple streams:
Cargo.toml:8
...
...
Besides this manifest remains the same as well as build script and library so their contents are at the bottom.
Until now we've changed the state of the clients only based on their requests and it made sense, but now we'll update the todo list based on server sent events initiated by other users adding or modifying their todos. Render method of our todo won't be based on the Render
trait anymore because it will need additional user data to disable controls for non-owners. Also, we won't be returning markup from add
, toggle
and delete
handlers anymore but instead use them to modify the data accordingly and broadcast the changes to all active clients:
src/main.rs
use prest::*;
use todo_pwa_auth_sync::{into_page, shared_routes};
embed_build_output_as!(BuiltAssets);
state!(TODO_UPDATES: SseBroadcast<Option<Todo>> = { SseBroadcast::default() });
#[derive(Table, Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
struct Todo {
#[serde(default = "Uuid::now_v7")]
pub id: Uuid,
#[serde(default)]
pub owner: Uuid,
pub task: String,
pub done: bool,
}
impl Todo {
fn render_for(&self, maybe_user: &Option<User>) -> Markup {
let owned = maybe_user
.as_ref()
.map(|u| u.id == self.owner)
.unwrap_or(false);
html! {
$"flex justify-between items-center" sse-swap=(self.id) vals=(json!(self)) {
input type="checkbox" patch="/todos" disabled[!owned] checked[self.done] {}
label $"ml-4 text-lg" {(self.task)}
button $"ml-auto" delete="/todos" disabled[!owned] {"Delete"}
}
}
}
}
fn main() {
init!(tables Todo);
shared_routes()
.route(
"/todos",
get(|auth: Auth| async move {
ok(html!(
@if auth.user.is_some() {
form put="/todos" swap-none after-request="this.reset()" {
input $"border rounded-md" type="text" name="task" {}
button $"ml-4" type="submit" {"Add"}
}
} @else {
form $"flex flex-col gap-4 items-center" method="POST" action=(LOGIN_ROUTE) {
input $"border rounded-md mx-4" type="text" name="username" placeholder="username" {}
input $"border rounded-md mx-4" type="password" name="password" placeholder="password" {}
input type="hidden" name="signup" value="true" {}
button $"ml-4" type="submit" {"Sign in / Sign up"}
}
}
div #"todos" $"w-full" sse="/todos/subscribe" sse-msg="add" place-end {
@for item in Todo::find_all()? {(item.render_for(&auth.user))}
}
))
})
.put(|user: User, Vals(mut todo): Vals<Todo>| async move {
todo.owner = user.id;
todo.save()?;
TODO_UPDATES.send("add", Some(todo)).await?;
OK
})
.patch(|user: User, Vals(mut todo): Vals<Todo>| async move {
if !todo.check_owner(user.id)? {
return Err(Error::Unauthorized);
}
todo.update_done(!todo.done)?;
TODO_UPDATES.send(todo.id.to_string(), Some(todo)).await?;
OK
})
.delete(|user: User, Vals(todo): Vals<Todo>| async move {
if !todo.check_owner(user.id)? {
return Err(Error::Unauthorized);
}
todo.remove()?;
TODO_UPDATES.send(todo.id.to_string(), None).await?;
OK
}),
)
.wrap_non_htmx(into_page)
.route(
"/todos/subscribe",
get(|auth: Auth| async {
TODO_UPDATES.stream_and_render(move |_event, todo| {
todo.map(|t| t.render_for(&auth.user)).unwrap_or_default()
})
}),
)
.embed(BuiltAssets)
.run();
}
We're using the htmx's sse
extension which allows us to easily swap events payloads into the right places based on their names. It starts with the hx-ext="sse"
and sse-connect="/todos/subscribe"
attributes which activate the extension and connect to the specified route to listen for events. Then sse-swap="EVENT_NAME"
attributes can be used on it and its children to listen to events with specified names and swap if got any. In this case we're using add
to append and todo's id
as names to make sure events reach the right places.
Now we have an installable collaborative real-time full-stack app! No react or another frontend framework involved and without even writing js. This is the end(for now) of the tutorials series, but you can also check out other examples from the menu.
Remaining code used in this example:
Cargo.toml
[package]
name = "todo-pwa-auth-sync"
edition = "2021"
[dependencies]
prest = { path = "../../", version = "0.4", features = ["auth"] }
wasm-bindgen = "0.2"
[build-dependencies]
prest-build = { version = "0.3", path = "../../build" }
build.rs
use prest_build::*;
fn main() {
default_cfg_aliases();
build_pwa(PWAOptions::new()).unwrap();
}
src/lib.rs
use prest::*;
pub fn shared_routes() -> Router {
route("/", get(home))
}
async fn home() -> Markup {
into_page(html!(
a get="/todos" trigger="load" push-url
after-request="if (!event.detail.successful) { document.getElementById('error').style.display = 'flex'; this.remove() }" {}
div #"error" style="display: none;" {"Couldn't connect to the server :("}
))
.await
}
pub async fn into_page(content: Markup) -> Markup {
html! { html { (Head::with_title("Todo PWA app with auth and sync"))
body $"max-w-screen-sm mx-auto px-8 mt-12 flex flex-col items-center" {
(content)
(Scripts::default())
}
}}
}
#[cfg(sw)]
#[wasm_bindgen(start)]
pub fn main() {
shared_routes().handle_fetch_events()
}