diff --git a/ROADMAP.md b/ROADMAP.md index d8f8c50..a68598f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -265,18 +265,69 @@ pid) + spawns the relay, `broadcast_from(self_pid(), ..)` for no-echo. ## v0.6 — Channels -Phoenix channels: the join/leave/event protocol over WebSocket transport, -PubSub underneath. +Join/leave/event protocol over WebSocket transport, PubSub underneath. +Design decisions ratified pre-code (2026-06-12): -- `Channel` trait: `join(topic, payload, socket)`, `handle_in(event, - payload, socket)`, `handle_out`, `terminate`. -- Topic router (`"room:*"` patterns) → channel actor per (conn, topic), - spawned under the ws connection, linked so conn death reaps channels. -- Wire format: Phoenix V2 JSON serializer - (`[join_ref, ref, topic, event, payload]`) — free interop with - phoenix.js clients is the killer feature; needs a JSON dep or a - hand-rolled mini-codec (decision point — this would be dep #3). -- Presence: explicitly out of scope until distribution exists somewhere. +**Feature flags.** Two additive features: +- `"channels"` — the `Channel` trait, `TopicRouter` trait, `PrefixRouter`, + `ChannelSocket`, session machinery. Zero new deps; pubsub is already + always-on (it only exists because channels needs it). +- `"phoenix"` — implies `"channels"` + `serde`/`serde_json` + the V2 frame + codec. Opt-in phoenix.js interop; users who want protobuf or any other + wire format take `"channels"` only and supply their own codec. + +**Payload generics.** The `Channel` trait is generic over payload: +`P: Encode + Decode` where `Encode`/`Decode` are urus codec traits (one +method each). The `"phoenix"` feature provides a `JsonCodec` blanket impl +via serde. No JSON anywhere in the `"channels"` surface. + +**`Channel` trait shape.** +``` +trait Channel

: Send + 'static { + fn join(topic: &str, payload: P, socket: &ChannelSocket

) -> Result; + fn handle_in(&mut self, event: &str, payload: P, socket: &ChannelSocket

); + fn terminate(&mut self) {} +} +``` +`handle_out` deferred — can land as a defaulted method, non-breaking. + +**`ChannelSocket`.** Newtype over `WsSender` + a `PubSub` handle. +Exposes `reply(ref, status, payload)`, `push(event, payload)`, +`broadcast(topic, payload)`. The `ref` from the incoming frame is threaded +in by the channel actor loop, invisible to the impl. Coupling to `PubSub` +is intentional: channels owns pubsub, and broadcast is the core use case. + +**Topic router.** `TopicRouter` trait: `fn route(&self, topic: &str) -> +Option>>`. Factory receives the full raw topic +string so wildcard-segment extraction is always possible. urus ships +`PrefixRouter` as the default impl: scan to `'*'`, discard the rest, +single `HashMap` lookup on the prefix. No regex dep, no glob machinery — +users who need that implement `TopicRouter` themselves. + +**Channel actor lifetime + session persistence.** +Default: channel actor linked to the ws connection actor, cold-start on +every reconnect (phoenix-server-compatible behavior). Opt-in persistence +via a `ChannelSession` trait: +``` +trait ChannelSession: Send + 'static { + type Key: Eq + Hash + Send + 'static; + fn session_key(topic: &str, join_payload: &P) -> Self::Key; + fn buffer_cap() -> usize { 128 } + fn ttl() -> Duration { Duration::from_secs(30) } +} +``` +When implemented, the router consults a session registry (gen_server, +same pattern as the connection registry) before spawning: an existing +actor is reattached, and buffered outbound messages (`Vec>`, no +re-serialisation) are drained to the reconnecting transport. Buffer full +or TTL expired → actor tears down; next join is a cold start. This +diverges from Phoenix server conventions (client-re-syncs) but is +invisible to phoenix.js at the wire level. Zero-copy drain is the smarm +motivation: messages are `Arc` in the buffer and in the PubSub relay +path, one allocation per broadcast regardless of subscriber count or +reconnect cycles. + +**Presence:** explicitly out of scope until distribution exists. ---