Files
urus/examples/channels_chat.rs
T
Claude b37888ec2c docs+examples: v0.3 endpoint API; crud converted to the app-owns-the-tree shape
- crud is now the demonstrator: app owns smarm runtime + root supervisor,
  store actor and urus::endpoint as ordered siblings (store first, so
  reverse-order shutdown drains HTTP before stopping the store),
  Shutdown::Infinity on the endpoint, shutdown via
  rt.handle().request_shutdown(root_sup) from the stdin thread.
  Deletes two kludges the old shape forced:
    * static OnceLock<Sender> spawn-on-first-use -> a supervised child
      that self-registers a typed Name; handlers use smarm::send per
      request and turn 'between incarnations' into a 503 instead of
      panicking on a dropped store.
    * static SHUTTING_DOWN AtomicBool + 250ms recv_timeout poll in the
      store loop and in the SSE ticker -> a plain park; the tree stops
      both. Smoke-tested live: CRUD round-trips, SSE stream, clean drain
      with the stream open, port closed after.
- Other examples stay short and on serve*, updated for the split config
  (serve_with(cfg, smarm::Config, pipe) / serve_with_shutdown(..., signal)).
  plain_serve's URUS_SCHED_THREADS now builds a smarm::Config.
  ws_chat's doc block explains the OnceLock is a serve*-only workaround
  and points at crud for the clean shape.
- README: new 'Your Own Supervision Tree' section (endpoint as the real
  API), graceful shutdown reframed as the serve*-only path, Config table
  loses scheduler_threads and gains name, PubSub rule 1 notes the
  supervised-sibling alternative.

111 lib + 50 integration + 2 doc tests green; clippy clean.
2026-08-20 13:20:28 +00:00

123 lines
4.6 KiB
Rust

//! Phoenix-style channels (v0.6): join/leave/event protocol over the
//! v0.4 WebSocket duplex, pubsub underneath, phoenix V2 JSON on the
//! wire.
//!
//! cargo run --example channels_chat --features phoenix
//! websocat ws://127.0.0.1:8080/socket (run two of these)
//! ["1","1","room:lobby","phx_join",{"name":"alice"}]
//! ["1","2","room:lobby","shout",{"text":"hi"}]
//! ["1","3","room:lobby","phx_leave",{}]
//!
//! The shapes this demonstrates:
//!
//! - **One `ChannelHub` for the whole app**, built lazily on the first
//! connection via the NON-static `Arc<OnceLock<...>>` pattern (the
//! hub spawns the pubsub table; same in-runtime + shutdown laws as
//! `examples/ws_chat.rs`, see the docs there).
//!
//! - **A channel per joined topic, not per socket.** `Room` never sees
//! frames, refs, or the transport heartbeat — the conn-side handler
//! answers heartbeats, routes `phx_join`, and the per-join channel
//! actor (linked to the connection) runs the callbacks sequentially.
//!
//! - **`Json<T>` is the phoenix codec's opt-in newtype.** The
//! `"channels"` core is wire-neutral (`Encode`/`Decode`, zero deps);
//! `Json<Value>` here buys phoenix.js V2 interop. Typed payloads work
//! the same way: `Json<MyPayload>` for any serde type.
//!
//! - **`session::*` topics persist across reconnects** (opt-in via
//! `channel_session`): drop a websocat mid-room, reconnect and rejoin
//! within 30s, and the broadcasts you missed drain to you in order —
//! the buffer holds the relay path's own `Arc`s, nothing is copied.
//! The session is keyed by the `"name"` in the join payload.
use std::sync::{Arc, OnceLock};
use serde_json::{json, Value};
use urus::channels::phoenix::Json;
use urus::{
serve_with_shutdown, shutdown_handle, Channel, ChannelHub, ChannelSession, ChannelSocket,
Config, Conn, Next, Pipeline, PrefixRouter, Router, Status,
};
type P = Json<Value>;
/// One chat room. State lives across callbacks (and, for `session:*`
/// topics, across transports).
#[derive(Default)]
struct Room {
name: String,
msgs_seen: u64,
}
impl Channel<P> for Room {
fn join(&mut self, topic: &str, payload: P, _s: &ChannelSocket<P>) -> Result<P, P> {
// Re-entrant by contract for session topics: a rejoin lands
// here again on the same instance — re-auth, then ack.
self.name = payload.0["name"].as_str().unwrap_or("anon").to_string();
Ok(Json(json!({ "joined": topic, "as": self.name, "seen": self.msgs_seen })))
}
fn handle_in(&mut self, event: &str, payload: P, s: &ChannelSocket<P>) {
match event {
"shout" => {
self.msgs_seen += 1;
s.broadcast("shouted", Json(json!({ "from": self.name, "msg": payload.0 })));
}
_ => s.reply(Status::Ok, Json(json!({ "echo": event }))),
}
}
fn terminate(&mut self) {
println!("room channel for {:?} is over", self.name);
}
}
/// Session config for `session:*`: the join payload's `"name"` is the
/// session token. Defaults: 128 buffered broadcasts, 30s TTL.
struct BySessionName;
impl ChannelSession<P> for BySessionName {
type Key = String;
fn session_key(topic: &str, join_payload: &P) -> String {
format!("{topic}/{}", join_payload.0["name"].as_str().unwrap_or("anon"))
}
}
fn main() {
let hub: Arc<OnceLock<ChannelHub<P>>> = Arc::new(OnceLock::new());
let router = Router::new().get("/socket", move |c: Conn, _n: Next| {
// Hub construction is in-runtime only (it spawns the pubsub
// table and, for session patterns, their registries); the
// non-static cell is what lets shutdown drain them all.
let hub = hub.get_or_init(|| {
ChannelHub::new(
PrefixRouter::new()
.channel_default::<Room>("room:*")
.channel_session::<BySessionName>("session:*", |_: &str| {
Box::new(Room::default()) as Box<dyn Channel<P>>
}),
)
});
hub.upgrade(c)
});
let (handle, signal) = shutdown_handle();
std::thread::spawn(move || {
println!("channels_chat on ws://127.0.0.1:8080/socket — press Enter to shut down");
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
handle.shutdown();
});
serve_with_shutdown(
Config::new("0.0.0.0:8080".parse().unwrap()),
smarm::Config::default(),
Pipeline::new().plug(router),
signal,
)
.unwrap();
println!("channels_chat: drained, bye");
}