docs(roadmap): ratify v0.6 Channels design
User-ratified 2026-06-12. Resolves the v0.6 fork left by the v0.5 handoff: dep #4 is serde/serde_json, but ONLY behind an opt-in 'phoenix' feature (V2 codec + JsonCodec). The 'channels' core takes zero new deps — payload generic over urus Encode/Decode traits. Channel trait shape, ChannelSocket, TopicRouter/PrefixRouter, and opt-in ChannelSession persistence (zero-copy Arc<M> buffer drain) all specified in the v0.6 section.
This commit is contained in:
+62
-11
@@ -265,18 +265,69 @@ pid) + spawns the relay, `broadcast_from(self_pid(), ..)` for no-echo.
|
|||||||
|
|
||||||
## v0.6 — Channels
|
## v0.6 — Channels
|
||||||
|
|
||||||
Phoenix channels: the join/leave/event protocol over WebSocket transport,
|
Join/leave/event protocol over WebSocket transport, PubSub underneath.
|
||||||
PubSub underneath.
|
Design decisions ratified pre-code (2026-06-12):
|
||||||
|
|
||||||
- `Channel` trait: `join(topic, payload, socket)`, `handle_in(event,
|
**Feature flags.** Two additive features:
|
||||||
payload, socket)`, `handle_out`, `terminate`.
|
- `"channels"` — the `Channel` trait, `TopicRouter` trait, `PrefixRouter`,
|
||||||
- Topic router (`"room:*"` patterns) → channel actor per (conn, topic),
|
`ChannelSocket`, session machinery. Zero new deps; pubsub is already
|
||||||
spawned under the ws connection, linked so conn death reaps channels.
|
always-on (it only exists because channels needs it).
|
||||||
- Wire format: Phoenix V2 JSON serializer
|
- `"phoenix"` — implies `"channels"` + `serde`/`serde_json` + the V2 frame
|
||||||
(`[join_ref, ref, topic, event, payload]`) — free interop with
|
codec. Opt-in phoenix.js interop; users who want protobuf or any other
|
||||||
phoenix.js clients is the killer feature; needs a JSON dep or a
|
wire format take `"channels"` only and supply their own codec.
|
||||||
hand-rolled mini-codec (decision point — this would be dep #3).
|
|
||||||
- Presence: explicitly out of scope until distribution exists somewhere.
|
**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<P>: Send + 'static {
|
||||||
|
fn join(topic: &str, payload: P, socket: &ChannelSocket<P>) -> Result<P, P>;
|
||||||
|
fn handle_in(&mut self, event: &str, payload: P, socket: &ChannelSocket<P>);
|
||||||
|
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<Box<dyn ChannelFactory<P>>>`. 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<Arc<M>>`, 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<M>` 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user