beck_rt/
patch.rs

1//! Patch frames and their encodings.
2//!
3//! §4.4 specifies a compact, field-tagged binary encoding for Beck↔Beck traffic. The thin client
4//! is a browser: JSON costs it *zero bytes of decoder*, and the decoder is the scarce resource in a
5//! 10 KB budget. Phase 0 therefore ships JSON on the wire and keeps the binary encoding alongside
6//! it so the trade is a measured number rather than an opinion — `beck-p0-bench payload` reports
7//! both, and Phase 1 can move the client to binary knowing exactly what it buys.
8//!
9//! Every frame carries the `seq` it brings the subscriber up to. That single field is what makes
10//! `(subscription, seq)` resumption and, later, optimistic reconciliation cheap (§4.4, §3.7).
11
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14
15use crate::log::Seq;
16use beck_core::diff::Op;
17use beck_core::html::Html;
18
19/// A subscription id — content-independent, minted by the client, stable across reconnects.
20pub type SubId = String;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct PatchFrame {
24    pub seq: Seq,
25    pub ops: Vec<Op>,
26}
27
28impl PatchFrame {
29    pub fn new(seq: Seq, ops: Vec<Op>) -> Self {
30        Self { seq, ops }
31    }
32
33    pub fn is_empty(&self) -> bool {
34        self.ops.is_empty()
35    }
36
37    /// The encoding the thin client consumes: `{"t":"p","q":<seq>,"o":[<op>...]}`.
38    pub fn to_json(&self) -> Value {
39        json!({
40            "t": "p",
41            "q": self.seq,
42            "o": self.ops.iter().map(Op::to_wire).collect::<Vec<_>>(),
43        })
44    }
45}
46
47/// A **data** patch frame: what a Mode B subscription carries instead of DOM ops (§5.1).
48///
49/// Two shapes rather than one, and the difference is the same one [`Resumption`] draws for Mode A:
50/// a client with nothing gets the accumulator, and a client with a position gets the difference
51/// from it. `beck_core::delta` is the vocabulary; this is the envelope it travels in.
52///
53/// [`Resumption`]: crate::protocol::Resumption
54#[derive(Clone, Debug, PartialEq)]
55pub enum DataFrame {
56    /// `{"t":"s","q":<seq>,"v":<state>}` — the whole accumulator, for a client with no position.
57    Whole {
58        seq: Seq,
59        state: beck_core::repr::Repr,
60    },
61    /// `{"t":"d","q":<seq>,"o":[<op>...]}` — the difference, for a client that has one.
62    Ops {
63        seq: Seq,
64        ops: Vec<beck_core::delta::Op>,
65    },
66}
67
68impl DataFrame {
69    /// The whole accumulator, or nothing if it holds something unstorable — which the checker
70    /// makes unreachable (`B0411`), and which is a refusal rather than a fabrication here.
71    pub fn whole(seq: Seq, state: &beck_core::Value) -> Option<DataFrame> {
72        beck_core::repr::Repr::of(state)
73            .ok()
74            .map(|state| DataFrame::Whole { seq, state })
75    }
76
77    pub fn seq(&self) -> Seq {
78        match self {
79            DataFrame::Whole { seq, .. } | DataFrame::Ops { seq, .. } => *seq,
80        }
81    }
82
83    pub fn is_empty(&self) -> bool {
84        matches!(self, DataFrame::Ops { ops, .. } if ops.is_empty())
85    }
86
87    pub fn to_json(&self) -> Value {
88        match self {
89            DataFrame::Whole { seq, state } => json!({"t": "s", "q": seq, "v": state}),
90            DataFrame::Ops { seq, ops } => json!({"t": "d", "q": seq, "o": ops}),
91        }
92    }
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum Codec {
97    /// What the thin client speaks.
98    Json,
99    /// §4.4's field-tagged binary encoding, measured but not yet shipped to the browser.
100    Postcard,
101}
102
103impl Codec {
104    pub fn encode(self, frame: &PatchFrame) -> Vec<u8> {
105        match self {
106            Codec::Json => serde_json::to_vec(&frame.to_json()).expect("frame is serialisable"),
107            Codec::Postcard => {
108                postcard::to_allocvec(&WireFrame::from(frame)).expect("frame is serialisable")
109            }
110        }
111    }
112}
113
114/// The binary mirror of a frame: same information, no structural hashes, tags instead of names.
115#[derive(Serialize, Deserialize)]
116struct WireFrame {
117    seq: Seq,
118    ops: Vec<WireOp>,
119}
120
121#[derive(Serialize, Deserialize)]
122enum WireOp {
123    Replace(Vec<u32>, WireHtml),
124    SetText(Vec<u32>, String),
125    SetAttr(Vec<u32>, String, String),
126    RemoveAttr(Vec<u32>, String),
127    Insert(Vec<u32>, u32, WireHtml),
128    Remove(Vec<u32>, u32),
129    Move(Vec<u32>, u32, u32),
130}
131
132#[derive(Serialize, Deserialize)]
133enum WireHtml {
134    Text(String),
135    El {
136        tag: String,
137        attrs: Vec<(String, String)>,
138        key: Option<String>,
139        children: Vec<WireHtml>,
140    },
141}
142
143impl From<&PatchFrame> for WireFrame {
144    fn from(frame: &PatchFrame) -> Self {
145        WireFrame {
146            seq: frame.seq,
147            ops: frame.ops.iter().map(WireOp::from).collect(),
148        }
149    }
150}
151
152impl From<&Op> for WireOp {
153    fn from(op: &Op) -> Self {
154        match op {
155            Op::Replace { path, html } => WireOp::Replace(path.clone(), html.into()),
156            Op::SetText { path, text } => WireOp::SetText(path.clone(), text.clone()),
157            Op::SetAttr { path, name, value } => {
158                WireOp::SetAttr(path.clone(), name.clone(), value.clone())
159            }
160            Op::RemoveAttr { path, name } => WireOp::RemoveAttr(path.clone(), name.clone()),
161            Op::Insert { path, index, html } => WireOp::Insert(path.clone(), *index, html.into()),
162            Op::Remove { path, index } => WireOp::Remove(path.clone(), *index),
163            Op::Move { path, from, to } => WireOp::Move(path.clone(), *from, *to),
164        }
165    }
166}
167
168impl From<&Html> for WireHtml {
169    fn from(html: &Html) -> Self {
170        match html {
171            Html::Text { text, .. } => WireHtml::Text(text.clone()),
172            Html::Element {
173                tag,
174                attrs,
175                key,
176                children,
177                ..
178            } => WireHtml::El {
179                tag: tag.clone(),
180                attrs: attrs.clone(),
181                key: key.clone(),
182                children: children.iter().map(|c| WireHtml::from(&**c)).collect(),
183            },
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use beck_core::diff::diff;
192
193    /// A rendered list of `n` rows, as the compiled `view` produces one.
194    fn list(n: usize, done: Option<usize>) -> Html {
195        Html::el("ul").children((0..n).map(|i| {
196            let row = Html::el("li").key(i.to_string());
197            let row = if done == Some(i) {
198                row.attr("class", "done")
199            } else {
200                row
201            };
202            row.child(Html::text(format!("todo {i}")))
203        }))
204    }
205
206    #[test]
207    fn a_single_toggle_is_a_small_frame_in_both_encodings() {
208        let ops = diff(&list(50, None), &list(50, Some(7)));
209        let frame = PatchFrame::new(51, ops);
210        let json = Codec::Json.encode(&frame);
211        let binary = Codec::Postcard.encode(&frame);
212
213        // The point of a patch stream: a 50-row list costs bytes proportional to the change, not
214        // to the list. This is the property the whole Mode A design rests on.
215        assert!(json.len() < 100, "json frame was {} bytes", json.len());
216        assert!(binary.len() < json.len());
217    }
218
219    #[test]
220    fn the_patch_is_essentially_constant_in_the_size_of_the_list() {
221        let small = Codec::Json.encode(&PatchFrame::new(
222            1,
223            diff(&list(10, None), &list(10, Some(3))),
224        ));
225        let large = Codec::Json.encode(&PatchFrame::new(
226            1,
227            diff(&list(1000, None), &list(1000, Some(3))),
228        ));
229        assert!(
230            large.len() <= small.len() + 8,
231            "small {} vs large {}",
232            small.len(),
233            large.len()
234        );
235    }
236}