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}