Skip to main content

ocre/
realtime.rs

1//! Realtime updates: WebSocket channels on a Durable Object, HTML broadcasts for htmx (feature `realtime`).
2//!
3//! Works like Rails' Action Cable and Turbo Streams: browsers subscribe to a
4//! named channel over a WebSocket, and handlers or jobs
5//! [`broadcast`] HTML fragments (or JSON) to every
6//! subscriber.
7//!
8//! ```no_run
9//! use axum::{Router, extract::{Path, State}, response::Response, routing::{get, post}};
10//! use ocre::{Ctx, Error, Result, realtime::{self, WebSocketUpgrade}};
11//!
12//! // src/realtime.rs: who may listen to which channel (`ocre g scaffold ... --realtime` writes it).
13//! async fn connect(State(ctx): State<Ctx>, Path(channel): Path<String>, upgrade: WebSocketUpgrade) -> Result<Response> {
14//!     match channel.as_str() {
15//!         "posts" => {}
16//!         _ => return Err(Error::NotFound),
17//!     }
18//!     upgrade.connect(&ctx, &channel).await
19//! }
20//!
21//! // Any handler or job: the new row goes to the top of every open index page.
22//! async fn create(State(ctx): State<Ctx>) -> Result<&'static str> {
23//!     let row_html = "<tr id=\"post_1\"><td>Hello</td></tr>";
24//!     realtime::broadcast(&ctx, "posts", &realtime::prepend("posts", row_html)).await?;
25//!     Ok("created")
26//! }
27//!
28//! fn routes() -> Router<Ctx> {
29//!     Router::new().route("/realtime/{channel}", get(connect)).route("/posts", post(create))
30//! }
31//! # let _ = routes;
32//! ```
33//!
34//! In the page, htmx's WebSocket extension connects and swaps each message
35//! into the element with the same `id` (`hx-swap-oob`), without custom
36//! JavaScript. Load the extension in the layout's `<head>`, after htmx: a
37//! page that loaded it itself would not connect when reached through an
38//! `hx-boost` link.
39//!
40//! ```html
41//! <!-- templates/layout.html, in <head> -->
42//! <script src="https://unpkg.com/htmx-ext-ws@2.0.4/dist/ws.js" crossorigin="anonymous"></script>
43//! <!-- the page -->
44//! <div hx-ext="ws" ws-connect="/realtime/posts">
45//!   <table><tbody id="posts">...<tr id="post_1">...</tr></tbody></table>
46//! </div>
47//! ```
48//!
49//! Messages: an element with an `id` replaces the page element with that id;
50//! [`append`], [`prepend`],
51//! [`update`] and [`remove`]
52//! build the other swaps. One message may hold several of them.
53//!
54//! How it runs: one Durable Object of class [`OcreChannel`] (binding
55//! `CHANNELS`) per channel name holds the channel's WebSockets with the
56//! WebSocket Hibernation API, so it is evicted from memory, and costs no
57//! duration, between broadcasts while browsers stay connected. It stores
58//! nothing.
59//!
60//! Free plan (see the README's Realtime section): each connection (and
61//! reconnection) and each broadcast is one Durable Object request (100,000 a
62//! day); messages sent to browsers are free; a connection is also one Worker
63//! request, while a broadcast is a subrequest of the request that sends it.
64//! Needs Ocre's `realtime` feature and, in cloudflare.config.ts, the binding and
65//! the SQLite-backed `OcreChannel` export (see [`OcreChannel`]).
66//! API-only apps can use the same pieces and broadcast JSON.
67//!
68//! Rails' Action Cable pieces map as follows: the connect handler is the
69//! connection (`identified_by` is [`WebSocketUpgrade::identified_by`], its
70//! checks and `Result` are the callbacks and `rescue_from`), the channel
71//! name and the route's query string are the channel params, client actions
72//! (`perform`) are ordinary routes that broadcast, and
73//! [`WebSocketUpgrade::rebroadcast`] relays what clients send to the other
74//! subscribers. [`dev_routes`] lists recent broadcasts for tests.
75
76use axum::{Router, extract::FromRequestParts, http::request::Parts};
77use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
78use serde::{Deserialize, Serialize};
79
80pub use crate::runtime::realtime::{OcreChannel, broadcast};
81
82use crate::{
83    Error,
84    protect::is_websocket_upgrade,
85    session::{Rejection, reject},
86};
87
88/// Name of the Durable Object binding holding the channels, declared in cloudflare.config.ts.
89///
90/// A missing binding makes [`broadcast`] and [`WebSocketUpgrade::connect`]
91/// fail with [`Error::Internal`] naming the cloudflare.config.ts entries to add.
92pub const CHANNELS_BINDING: &str = "CHANNELS";
93/// Name of the Durable Object class Ocre exports for channels ([`OcreChannel`]).
94///
95/// The `exportName` of the `CHANNELS` binding and the `exports` key in
96/// cloudflare.config.ts must use it.
97pub const CHANNEL_CLASS: &str = "OcreChannel";
98/// Prefix of every line Ocre logs about realtime, e.g. in `ocre dev` output.
99///
100/// Failed broadcasts log `[ocre realtime] broadcast to posts failed: ...`.
101pub const LOG_PREFIX: &str = "[ocre realtime]";
102/// Longest channel name, in bytes.
103///
104/// Channel names are 1 to 128 ASCII letters, digits, `_`, `-`, `.` or `:`
105/// (e.g. `posts`, `post:12`), so they are safe in URLs and logs.
106pub const MAX_CHANNEL_LEN: usize = 128;
107/// Longest identity given to [`WebSocketUpgrade::identified_by`], in bytes.
108pub const MAX_IDENTITY_LEN: usize = 256;
109/// Longest client message [`WebSocketUpgrade::rebroadcast`] relays, in bytes; longer ones are dropped.
110pub const MAX_REBROADCAST_BYTES: usize = 16 * 1024;
111
112/// Header carrying a [`Subscriber`] from `connect` to the channel object.
113pub(crate) const SUBSCRIBER_HEADER: &str = "X-Ocre-Subscriber";
114
115/// What the channel object keeps with one WebSocket while it hibernates.
116#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
117pub(crate) struct Subscriber {
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub(crate) identity: Option<String>,
120    #[serde(default)]
121    pub(crate) rebroadcast: bool,
122}
123
124impl Subscriber {
125    /// The header value: the JSON, base64url-encoded so any identity is a valid header.
126    pub(crate) fn to_header(&self) -> String {
127        URL_SAFE_NO_PAD.encode(serde_json::to_vec(self).expect("a subscriber serializes"))
128    }
129
130    /// The subscriber a header describes; a plain listener without one or when it is unreadable.
131    pub(crate) fn from_header(value: Option<&str>) -> Self {
132        value
133            .and_then(|value| URL_SAFE_NO_PAD.decode(value).ok())
134            .and_then(|json| serde_json::from_slice(&json).ok())
135            .unwrap_or_default()
136    }
137
138    /// What the other subscribers receive when this one sends `text`:
139    /// `{"from": <identity or null>, "data": <text as JSON, or as a string>}`.
140    /// `None` when this subscriber may not publish or `text` is too long.
141    pub(crate) fn relay(&self, text: &str) -> Option<String> {
142        if !self.rebroadcast || text.len() > MAX_REBROADCAST_BYTES {
143            return None;
144        }
145        let data = serde_json::from_str(text).unwrap_or_else(|_| serde_json::Value::String(text.to_owned()));
146        Some(serde_json::json!({ "from": self.identity, "data": data }).to_string())
147    }
148
149    /// What the other subscribers receive when this one connects (`"joined"`)
150    /// or disconnects (`"left"`): `{"event": .., "from": <identity>}`, for
151    /// identified publishers only.
152    pub(crate) fn presence(&self, event: &str) -> Option<String> {
153        let identity = self.identity.as_ref().filter(|_| self.rebroadcast)?;
154        Some(serde_json::json!({ "event": event, "from": identity }).to_string())
155    }
156}
157
158/// Why `name` is not a valid channel name, or `None`: 1 to 128 ASCII letters,
159/// digits and `_ - . :`, so names are safe in URLs and logs.
160pub(crate) fn channel_error(name: &str) -> Option<String> {
161    let valid_chars = name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':'));
162    if name.is_empty() || name.len() > MAX_CHANNEL_LEN || !valid_chars {
163        Some(format!(
164            "invalid channel name `{name}`: use 1 to {MAX_CHANNEL_LEN} ASCII letters, digits, `_`, `-`, `.` or `:`"
165        ))
166    } else {
167        None
168    }
169}
170
171/// Error for a missing `CHANNELS` Durable Object binding.
172pub(crate) fn missing_binding(detail: &str) -> Error {
173    Error::internal(format!(
174        "Durable Object binding `{CHANNELS_BINDING}` is missing ({detail}). Fix: add to cloudflare.config.ts \
175         `{CHANNELS_BINDING}: bindings.durableObject({{ worker: \"<app>\", exportName: \"{CHANNEL_CLASS}\" }}),` in worker.env and \
176         `{CHANNEL_CLASS}: exports.durableObject({{ storage: \"sqlite\" }}),` in worker.exports \
177         (`ocre g scaffold <Model> ... --realtime` adds them)"
178    ))
179}
180
181/// A close code the server may send back: the client's own code, or 1000
182/// (normal closure) for codes that must not appear in a Close frame.
183pub(crate) fn close_code(code: usize) -> u16 {
184    match code {
185        1000..=1003 | 1007..=1014 | 3000..=4999 => code as u16,
186        _ => 1000,
187    }
188}
189
190/// Axum extractor for a WebSocket handshake (`Upgrade: websocket`), finished with [`connect`](Self::connect).
191///
192/// Check who may listen in the handler, then call
193/// [`connect`](Self::connect). Browsers send the session cookie with the
194/// handshake, so `CurrentUser` and [`Session`](crate::Session) work in the
195/// same handler. [`serve`](crate::serve) refuses handshakes from other sites
196/// (403), as it does for forms.
197///
198/// Rejection: requests without `Upgrade: websocket` get
199/// [`Error::BadRequest`] (400), rendered as an HTML page in full-stack apps
200/// and as JSON in API-only apps.
201///
202/// Free plan: each connection (and each reconnection) is one Worker request
203/// and one Durable Object request (100,000 a day each); hibernated sockets
204/// cost nothing between messages.
205///
206/// # Examples
207///
208/// ```no_run
209/// use axum::{extract::{Path, State}, response::Response};
210/// use ocre::{Ctx, Result, realtime::WebSocketUpgrade};
211///
212/// // GET /realtime/{channel}
213/// async fn connect(State(ctx): State<Ctx>, Path(channel): Path<String>, upgrade: WebSocketUpgrade)
214///     -> Result<Response> {
215///     upgrade.connect(&ctx, &channel).await
216/// }
217/// ```
218#[derive(Debug)]
219pub struct WebSocketUpgrade {
220    pub(crate) subscriber: Subscriber,
221}
222
223impl WebSocketUpgrade {
224    /// Names who is connecting, like Action Cable's `identified_by :current_user`.
225    ///
226    /// The identity (a user id, a display name...) stays with the socket in
227    /// the channel object, even while it hibernates, and is the `from` of
228    /// the messages [`rebroadcast`](Self::rebroadcast) relays, so clients
229    /// cannot forge it. At most [`MAX_IDENTITY_LEN`] bytes; a longer one
230    /// makes [`connect`](Self::connect) fail with [`Error::Internal`].
231    ///
232    /// # Examples
233    ///
234    /// ```no_run
235    /// use axum::{extract::{Path, State}, response::Response};
236    /// use ocre::{Ctx, Result, realtime::WebSocketUpgrade};
237    /// # struct CurrentUser { id: i64 }
238    ///
239    /// async fn connect(State(ctx): State<Ctx>, Path(channel): Path<String>, upgrade: WebSocketUpgrade)
240    ///     -> Result<Response> {
241    ///     let user = CurrentUser { id: 7 }; // `user: CurrentUser` as an extractor after `ocre g auth`
242    ///     upgrade.identified_by(user.id.to_string()).connect(&ctx, &channel).await
243    /// }
244    /// ```
245    pub fn identified_by(mut self, identity: impl Into<String>) -> Self {
246        self.subscriber.identity = Some(identity.into());
247        self
248    }
249
250    /// Relays what this client sends to the channel's other subscribers, like a channel that rebroadcasts client data.
251    ///
252    /// Without it, messages from clients are ignored (they only listen). With
253    /// it, each text message of at most [`MAX_REBROADCAST_BYTES`] is sent to
254    /// every other socket of the channel as JSON:
255    /// `{"from": "<identity>", "data": <message>}`, where `from` is the
256    /// [`identified_by`](Self::identified_by) identity (`null` without one)
257    /// and `data` is the message parsed as JSON (a string when it is not
258    /// JSON). An identified publisher's arrival and departure reach the
259    /// others as `{"event": "joined", "from": "<identity>"}` and
260    /// `{"event": "left", ...}` (presence, Action Cable's `subscribed` and
261    /// `unsubscribed` hooks). No app code runs: use it for typing indicators, cursors or chat
262    /// between JavaScript clients; send anything that must be checked or
263    /// stored to an ordinary route that saves it and calls [`broadcast`].
264    /// Relayed messages are never HTML swaps, so a client cannot inject
265    /// markup into other pages.
266    ///
267    /// Free plan: Cloudflare bills incoming WebSocket messages to a Durable
268    /// Object at 20 messages per request (100,000 requests a day); relaying
269    /// is free.
270    ///
271    /// # Examples
272    ///
273    /// ```no_run
274    /// use axum::{extract::{Path, State}, response::Response};
275    /// use ocre::{Ctx, Result, realtime::WebSocketUpgrade};
276    ///
277    /// // GET /chat/{room}: `ws.send(JSON.stringify({text: "hi"}))` reaches the others as
278    /// // {"from":"ada","data":{"text":"hi"}}.
279    /// async fn chat(State(ctx): State<Ctx>, Path(room): Path<String>, upgrade: WebSocketUpgrade)
280    ///     -> Result<Response> {
281    ///     upgrade.identified_by("ada").rebroadcast().connect(&ctx, &format!("chat:{room}")).await
282    /// }
283    /// ```
284    pub fn rebroadcast(mut self) -> Self {
285        self.subscriber.rebroadcast = true;
286        self
287    }
288
289    /// Checks the identity before connecting.
290    pub(crate) fn subscriber_header(&self) -> Result<String, Error> {
291        match &self.subscriber.identity {
292            Some(identity) if identity.len() > MAX_IDENTITY_LEN => Err(Error::internal(format!(
293                "realtime identity is {} bytes, more than {MAX_IDENTITY_LEN}. Fix: identify subscribers by a short \
294                 value such as the user id",
295                identity.len()
296            ))),
297            _ => Ok(self.subscriber.to_header()),
298        }
299    }
300}
301
302impl<S: Sync> FromRequestParts<S> for WebSocketUpgrade {
303    type Rejection = Rejection;
304
305    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Rejection> {
306        if is_websocket_upgrade(&parts.headers) {
307            Ok(Self { subscriber: Subscriber::default() })
308        } else {
309            Err(reject(Error::bad_request(
310                "Expected a WebSocket connection (`Upgrade: websocket`). Connect with htmx's ws extension or `new WebSocket(url)`.",
311            )))
312        }
313    }
314}
315
316/// Development endpoint listing recent broadcasts, served by `ocre dev` only, for tests (Rails' `assert_broadcasts`).
317///
318/// `GET /ocre/dev/realtime/sent.json` answers the last 50 messages
319/// [`broadcast`] sent from this Worker instance (requests and jobs alike),
320/// oldest first: `[{"id": 1, "channel": "posts", "message": "<tr ...>"}]`.
321/// An end-to-end test triggers a change, then checks what went out without
322/// opening a WebSocket (`ocre::testing::Client` reads it). Only successful
323/// broadcasts are listed.
324///
325/// Debug builds only (`ocre dev`); release builds (`ocre deploy`) get an
326/// empty router, so it is a 404 in production. `ocre g scaffold ...
327/// --realtime` merges it into the routes of `src/realtime.rs`. It uses no
328/// billed resource: the list lives in the Worker's memory.
329///
330/// # Examples
331///
332/// ```
333/// use axum::{Router, routing::get};
334/// use ocre::Ctx;
335///
336/// # async fn connect() {}
337/// fn routes() -> Router<Ctx> {
338///     Router::new().route("/realtime/{channel}", get(connect)).merge(ocre::realtime::dev_routes())
339/// }
340/// # let _ = routes;
341/// ```
342pub fn dev_routes<S: Clone + Send + Sync + 'static>() -> Router<S> {
343    #[cfg(not(debug_assertions))]
344    {
345        Router::new()
346    }
347    #[cfg(debug_assertions)]
348    Router::new().route("/ocre/dev/realtime/sent.json", axum::routing::get(|| async { axum::Json(dev::sent()) }))
349}
350
351/// Remembers a sent broadcast for [`dev_routes`] (debug builds only).
352pub(crate) fn record(channel: &str, message: &str) {
353    #[cfg(debug_assertions)]
354    dev::record(channel, message);
355    #[cfg(not(debug_assertions))]
356    let _ = (channel, message);
357}
358
359#[cfg(debug_assertions)]
360mod dev {
361    use std::sync::{Mutex, PoisonError};
362
363    use serde::Serialize;
364
365    /// How many broadcasts are kept.
366    const KEEP: usize = 50;
367
368    #[derive(Debug, Clone, Serialize)]
369    pub(super) struct Sent {
370        id: u64,
371        channel: String,
372        message: String,
373    }
374
375    static SENT: Mutex<(u64, Vec<Sent>)> = Mutex::new((1, Vec::new()));
376
377    pub(super) fn record(channel: &str, message: &str) {
378        let mut sent = SENT.lock().unwrap_or_else(PoisonError::into_inner);
379        let id = sent.0;
380        sent.0 += 1;
381        sent.1.push(Sent { id, channel: channel.to_owned(), message: message.to_owned() });
382        if sent.1.len() > KEEP {
383            sent.1.remove(0);
384        }
385    }
386
387    pub(super) fn sent() -> Vec<Sent> {
388        SENT.lock().unwrap_or_else(PoisonError::into_inner).1.clone()
389    }
390}
391
392/// Builds a message that inserts `html` at the end of the element with id `target` (htmx `beforeend`).
393///
394/// The fragment is wrapped in an element the browser can parse it in (a
395/// `<tr>` goes in a `<tbody>`, an `<li>` in a `<ul>`, and likewise for other
396/// table parts, options and `<dt>`/`<dd>`; anything else in a `<div>`)
397/// carrying `hx-swap-oob`; htmx drops the wrapper. `target` is
398/// escaped; `html` is sent as is, so escape user content in it (askama
399/// templates do). Pure: no I/O until you [`broadcast`] it.
400///
401/// # Examples
402///
403/// ```
404/// assert_eq!(
405///     ocre::realtime::append("comments", "<li id=\"comment_2\">Hi</li>"),
406///     "<ul hx-swap-oob=\"beforeend:#comments\"><li id=\"comment_2\">Hi</li></ul>"
407/// );
408/// ```
409pub fn append(target: &str, html: &str) -> String {
410    swap("beforeend", target, html)
411}
412
413/// Builds a message that inserts `html` at the start of the element with id `target` (htmx `afterbegin`).
414///
415/// Typical use: a new row at the top of a table body. Wrapping and escaping
416/// work as in [`append`].
417///
418/// # Examples
419///
420/// ```
421/// assert_eq!(
422///     ocre::realtime::prepend("posts", "<tr id=\"post_3\"><td>Hi</td></tr>"),
423///     "<tbody hx-swap-oob=\"afterbegin:#posts\"><tr id=\"post_3\"><td>Hi</td></tr></tbody>"
424/// );
425/// ```
426pub fn prepend(target: &str, html: &str) -> String {
427    swap("afterbegin", target, html)
428}
429
430/// Builds a message that replaces the contents of the element with id `target` (htmx `innerHTML`).
431///
432/// The element itself stays; wrapping and escaping work as in [`append`].
433/// To replace a whole element, broadcast its new HTML with the same `id`
434/// instead.
435///
436/// # Examples
437///
438/// ```
439/// assert_eq!(
440///     ocre::realtime::update("post_count", "3 posts"),
441///     "<div hx-swap-oob=\"innerHTML:#post_count\">3 posts</div>"
442/// );
443/// ```
444pub fn update(target: &str, html: &str) -> String {
445    swap("innerHTML", target, html)
446}
447
448/// Builds a message that removes the element with id `id` from the page (htmx `delete`).
449///
450/// `id` is escaped.
451///
452/// # Examples
453///
454/// ```
455/// assert_eq!(ocre::realtime::remove("post_3"), "<div id=\"post_3\" hx-swap-oob=\"delete\"></div>");
456/// ```
457pub fn remove(id: &str) -> String {
458    format!("<div id=\"{}\" hx-swap-oob=\"delete\"></div>", escape(id))
459}
460
461/// Wraps `html` in an element the browser can parse it in (a `<tr>` only
462/// parses inside a table body), carrying the htmx swap. htmx drops the
463/// wrapper and swaps its children.
464fn swap(strategy: &str, target: &str, html: &str) -> String {
465    let wrapper = wrapper_for(html);
466    format!("<{wrapper} hx-swap-oob=\"{strategy}:#{}\">{html}</{wrapper}>", escape(target))
467}
468
469/// The parent element `html`'s first tag needs.
470fn wrapper_for(html: &str) -> &'static str {
471    let tag: String = html
472        .trim_start()
473        .strip_prefix('<')
474        .unwrap_or_default()
475        .chars()
476        .take_while(char::is_ascii_alphanumeric)
477        .map(|c| c.to_ascii_lowercase())
478        .collect();
479    match tag.as_str() {
480        "tr" => "tbody",
481        "td" | "th" => "tr",
482        "tbody" | "thead" | "tfoot" | "caption" | "colgroup" => "table",
483        "li" => "ul",
484        "option" | "optgroup" => "select",
485        "dt" | "dd" => "dl",
486        _ => "div",
487    }
488}
489
490/// Escapes an attribute value.
491fn escape(value: &str) -> String {
492    value.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;")
493}
494
495#[cfg(test)]
496#[path = "../tests/realtime.rs"]
497mod tests;