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

...
async-broadcast = "0.6"
...

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 async_broadcast::{broadcast, Receiver, Sender};
use prest::*;
use todo_pwa_auth_sync::{into_page, shared_routes};

embed_build_output_as!(BuiltAssets);

state!(BROADCAST: (Sender<BroadcastMsg>, Receiver<BroadcastMsg>) = { broadcast(1000) });

#[derive(Clone)]
struct BroadcastMsg {
    pub event: String,
    pub data: Option<Todo>,
}

#[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,
}

fn main() {
    init!(tables Todo);
    shared_routes()
        .route("/todos", get(todos).put(add).patch(toggle).delete(delete))
        .wrap_non_htmx(into_page)
        .route("/todos/subscribe", get(todos_subscribe))
        .embed(BuiltAssets)
        .run();
}

impl Todo {
    fn render_for(&self, user: &Option<User>) -> Markup {
        let owned = match user {
            Some(user) => user.id == self.owner,
            None => false,
        };
        html! {
            $"flex items-center" sse-swap=(self.id) hx-swap="outerHTML" hx-vals=(json!(self)) {
                input type="checkbox" hx-patch="/todos" disabled[!owned] checked[self.done] {}
                label $"ml-4 text-lg" {(self.task)}
                button $"ml-auto" hx-delete="/todos" disabled[!owned] {"Delete"}
            }
        }
    }
}

async fn todos(auth: Auth) -> Markup {
    html!(
        @if auth.user.is_some() {
            form hx-put="/todos" hx-swap="none" hx-on--after-request="this.reset()" {
                input $"border rounded-md" type="text" name="task" {}
                button $"ml-4" type="submit" {"Add"}
            }
        } @else {
            @if *WITH_GOOGLE_AUTH {
                a $"p-4 border rounded-md" href=(GOOGLE_LOGIN_ROUTE) {"Login with Google"}
                div {"OR"}
            }
            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"}
            }
        }
        #"todos" $"w-full" hx-ext="sse" sse-connect="/todos/subscribe" sse-swap="add" hx-swap="beforeend" {
            @for item in Todo::find_all() {(item.render_for(&auth.user))}
        }
    )
}

async fn todos_subscribe(auth: Auth) -> Sse<impl Stream<Item = SseItem>> {
    let stream = BROADCAST.1.new_receiver().map(move |msg| {
        let data = match msg.data {
            Some(todo) => todo.render_for(&auth.user).0,
            None => "".to_owned(),
        };
        SseEvent::default().event(msg.event.as_str()).data(data)
    });
    Sse::new(stream.map(Ok)).keep_alive(SseKeepAlive::default())
}

async fn add(user: User, Form(mut todo): Form<Todo>) -> Result {
    todo.owner = user.id;
    todo.save()?;
    BROADCAST
        .0
        .broadcast_direct(BroadcastMsg {
            event: "add".to_owned(),
            data: Some(todo),
        })
        .await
        .map_err(|e| anyhow!("{e}"))?;
    Ok(())
}

async fn toggle(user: User, Form(mut todo): Form<Todo>) -> Result {
    if !todo.check_owner(user.id)? {
        return Err(Error::Unauthorized);
    }
    todo.update_done(!todo.done)?;
    BROADCAST
        .0
        .broadcast_direct(BroadcastMsg {
            event: todo.id.to_string(),
            data: Some(todo),
        })
        .await
        .map_err(|e| anyhow!("{e}"))?;
    Ok(())
}
async fn delete(user: User, Query(todo): Query<Todo>) -> Result {
    if !todo.check_owner(user.id)? {
        return Err(Error::Unauthorized);
    }
    todo.remove()?;
    BROADCAST
        .0
        .broadcast_direct(BroadcastMsg {
            event: todo.id.to_string(),
            data: None,
        })
        .await
        .map_err(|e| anyhow!("{e}"))?;
    Ok(())
}

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"
async-broadcast = "0.6"

[build-dependencies]
prest-build = "0.2"

build.rs

use prest_build::*;
fn main() {
    build_pwa(PWAOptions::default()).unwrap();
}

src/lib.rs

use prest::*;

pub fn shared_routes() -> Router {
    route("/", get(home))
}

async fn home() -> Markup {
    into_page(html!(
        a hx-get="/todos" hx-trigger="load" hx-swap="outerHTML" hx-push-url="true"
            hx-on--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()
}
v0.4.0
made by Egor Dezhic