beck_host/
protocol.rs

1//! The socket protocol: one connection multiplexing patches down and commands up (§5.1).
2//!
3//! Resumption is the load-bearing part. A subscriber reconnects with `(subscription, seq)` and the
4//! server replays the gap rather than re-rendering the world — which is what makes a deploy, or a
5//! dropped train tunnel, cost one small patch instead of a full page.
6//!
7//! One rule Phase 0 learned the hard way and §18.7 item 5 says to carry forward: **the ack tells
8//! you the command landed; the frame tells you where your view stands, and the two are different
9//! facts.** A command whose net effect is invisible in a subscriber's own view produces an empty
10//! diff and therefore no frame, so a client waiting for "the patch for my command" would wait
11//! forever. `up_to_date` is the answer, and it is sent only to a client waiting on its own
12//! command — never to idle ones, because a message per idle subscriber per event is precisely the
13//! fanout cost this design exists to avoid.
14
15use serde::Deserialize;
16use serde_json::{json, Value};
17
18use crate::record::Seq;
19
20#[derive(Clone, Debug, PartialEq, Deserialize)]
21#[serde(tag = "t")]
22pub enum ClientMsg {
23    /// Subscribe, or resume.
24    ///
25    /// `seq` is **what this client holds**, and its absence is meaningful: `None` is "I have
26    /// nothing, send me the world" and `Some(n)` is "I hold the frame as of `n`" — including
27    /// `Some(0)`, which is what a browser says when the document it is running in was rendered
28    /// from an empty log. Those were the same message until a browser ran the thin client and the
29    /// first paint rebuilt the page it had just been served (`docs/94` §94.13): position zero and
30    /// nothing-at-all are different facts, and a protocol that spells them the same way cannot
31    /// keep §5.1's "first paint is free" promise.
32    #[serde(rename = "hello")]
33    Hello {
34        sub: String,
35        #[serde(default)]
36        seq: Option<Seq>,
37        /// What the client says it is. **A claim, not an actor**: `beck_rt::identity` is what
38        /// turns it into one, and under `DevIdentity` the two are the same value — which is a
39        /// choice an operator makes rather than a property of the protocol (`docs/48`).
40        #[serde(default)]
41        actor: String,
42        /// Where this client is, as a route. Absent means the application's root.
43        ///
44        /// On the `hello` rather than only in a [`ClientMsg::Nav`] because a subscription is
45        /// re-established after every disconnection, and a client whose route were established by
46        /// a separate frame would render one page for as long as the two frames were in flight —
47        /// and would render the wrong page for as long as it took the second one to be *re*sent
48        /// after a reload with the network down.
49        #[serde(default = "root")]
50        path: String,
51    },
52    /// A proposal. `id` is the idempotency key that makes a retry after a reconnect safe (§4.3).
53    #[serde(rename = "c")]
54    Cmd { id: String, command: Value },
55    /// The client is somewhere else now.
56    ///
57    /// It travels on the same socket as the commands, which is the whole of the ordering argument:
58    /// a command proposed from a page is preceded by the navigation that produced that page, so
59    /// the `Session` the server hands `validate` is the one the client's own copy of `validate`
60    /// saw. Nothing had to be added to the command frame to make that true.
61    #[serde(rename = "g")]
62    Nav { path: String },
63    #[serde(rename = "ping")]
64    Ping,
65}
66
67fn root() -> String {
68    beck_core::edge::ROOT.to_string()
69}
70
71impl ClientMsg {
72    pub fn parse(text: &str) -> Result<ClientMsg, serde_json::Error> {
73        serde_json::from_str(text)
74    }
75}
76
77/// How a subscription was (re)established.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum Resumption {
80    /// First connection: the client has nothing, so the frame carries the whole view.
81    Fresh,
82    /// The client's `seq` was still reachable: it gets a patch covering exactly the gap.
83    Resumed { from: Seq, replayed: u64 },
84    /// The gap was unreachable (log truncated, or a `seq` from another lifetime of the app): the
85    /// client is reset with a full frame. Honest, and counted separately.
86    Reset { from: Seq },
87}
88
89impl Resumption {
90    pub fn label(&self) -> &'static str {
91        match self {
92            Resumption::Fresh => "fresh",
93            Resumption::Resumed { .. } => "resumed",
94            Resumption::Reset { .. } => "reset",
95        }
96    }
97}
98
99pub struct ServerMsg;
100
101impl ServerMsg {
102    pub fn welcome(sub: &str, seq: Seq, how: Resumption) -> Value {
103        let mut msg = json!({"t": "w", "sub": sub, "q": seq, "how": how.label()});
104        if let Resumption::Resumed { replayed, .. } = how {
105            msg["replayed"] = json!(replayed);
106        }
107        msg
108    }
109
110    pub fn ack(id: &str, seq: Seq) -> Value {
111        json!({"t": "a", "id": id, "q": seq})
112    }
113
114    /// "Your view is current as of `seq`, and nothing in it changed."
115    pub fn up_to_date(seq: Seq) -> Value {
116        json!({"t": "u", "q": seq})
117    }
118
119    pub fn nack(id: &str, why: &str) -> Value {
120        json!({"t": "n", "id": id, "e": why})
121    }
122
123    /// The connection is over before it began: the identity was refused.
124    ///
125    /// Coarse on purpose (`identity::Rejected::message`) — a client learns it was refused and not
126    /// which of the three ways, because the difference is useful to an attacker and to nobody
127    /// else. The operator gets the distinction, in the log.
128    pub fn error(why: &str) -> Value {
129        json!({"t": "e", "e": why})
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// Absence and zero are different messages, and the server reads them differently.
138    #[test]
139    fn a_hello_without_a_position_holds_nothing_and_one_with_zero_holds_the_first_page() {
140        let nothing = ClientMsg::parse(r#"{"t":"hello","sub":"s1","actor":"ana"}"#).unwrap();
141        assert!(matches!(nothing, ClientMsg::Hello { seq: None, .. }));
142        let painted =
143            ClientMsg::parse(r#"{"t":"hello","sub":"s1","seq":0,"actor":"ana"}"#).unwrap();
144        assert!(matches!(painted, ClientMsg::Hello { seq: Some(0), .. }));
145    }
146
147    #[test]
148    fn parses_a_resume_hello() {
149        let msg = ClientMsg::parse(r#"{"t":"hello","sub":"s1","seq":41,"actor":"alice"}"#).unwrap();
150        assert_eq!(
151            msg,
152            ClientMsg::Hello {
153                sub: "s1".into(),
154                seq: Some(41),
155                actor: "alice".into(),
156                path: "/".into(),
157            }
158        );
159    }
160
161    /// A client that says where it is, and one that does not.
162    ///
163    /// The default is the application's root rather than an empty string, because a program
164    /// matching on `session.path` should not have to spell "the client did not say" and "the client
165    /// is at the root" as two different pages — and every client that predates the router sends no
166    /// `path` at all.
167    #[test]
168    fn a_hello_carries_the_route_and_defaults_to_the_root() {
169        let deep =
170            ClientMsg::parse(r#"{"t":"hello","sub":"s1","actor":"ana","path":"/done"}"#).unwrap();
171        assert!(matches!(deep, ClientMsg::Hello { ref path, .. } if path == "/done"));
172        let silent = ClientMsg::parse(r#"{"t":"hello","sub":"s1","actor":"ana"}"#).unwrap();
173        assert!(matches!(silent, ClientMsg::Hello { ref path, .. } if path == "/"));
174    }
175
176    /// A navigation is its own frame, on the same socket as the commands — which is what makes the
177    /// `Session` the server hands `validate` the one the client's own `validate` saw.
178    #[test]
179    fn parses_a_navigation() {
180        assert_eq!(
181            ClientMsg::parse(r#"{"t":"g","path":"/done"}"#).unwrap(),
182            ClientMsg::Nav {
183                path: "/done".into()
184            }
185        );
186    }
187
188    #[test]
189    fn a_command_is_carried_untyped_and_decoded_against_the_programs_union() {
190        // The runtime decodes against `union Command` from the source; the protocol itself knows
191        // nothing about what commands exist, which is what makes it program-independent.
192        let msg = ClientMsg::parse(
193            r#"{"t":"c","id":"k1","command":{"c":"Toggle","id":"00000000-0000-0000-0000-000000000001"}}"#,
194        )
195        .unwrap();
196        match msg {
197            ClientMsg::Cmd { id, command } => {
198                assert_eq!(id, "k1");
199                assert_eq!(command["c"], "Toggle");
200            }
201            other => panic!("unexpected {other:?}"),
202        }
203    }
204
205    #[test]
206    fn the_welcome_frame_reports_how_resumption_went() {
207        let w = ServerMsg::welcome(
208            "s",
209            7,
210            Resumption::Resumed {
211                from: 3,
212                replayed: 4,
213            },
214        );
215        assert_eq!(w["how"], "resumed");
216        assert_eq!(w["replayed"], 4);
217        assert_eq!(
218            ServerMsg::welcome("s", 7, Resumption::Fresh)["how"],
219            "fresh"
220        );
221    }
222}