Skip to main content

cratefield_runtime_cloudflare/ports/
realtime.rs

1//! The `Realtime` port on Cloudflare: a Durable Object holds the sockets
2//! (issue #103).
3//!
4//! **The Durable Object class lives in the venture, not here.** `#[durable_object]`
5//! is a `wasm_bindgen` export, and a library crate cannot export one on a
6//! venture's behalf — the class has to be declared in the crate that is compiled
7//! into the Worker. So this ships the driver: a venture writes a small class and
8//! forwards each of its four entry points to [`RoomDriver`], which owns the
9//! protocol, the member identity and the calls into the module's
10//! [`RoomHandler`].
11//!
12//! ```ignore
13//! #[durable_object]
14//! pub struct Rooms { state: State, driver: RoomDriver }
15//!
16//! impl DurableObject for Rooms {
17//!     fn new(state: State, _env: Env) -> Self {
18//!         Self { state, driver: RoomDriver::new(Arc::new(MyRooms)) }
19//!     }
20//!     async fn fetch(&self, req: Request) -> Result<Response> {
21//!         let member = verify(&req)?;            // the venture's job, see below
22//!         self.driver.upgrade(&self.state, &member).await
23//!     }
24//!     async fn websocket_message(&self, ws: WebSocket, message: WebSocketIncomingMessage)
25//!         -> Result<()> { self.driver.message(&self.state, &ws, message).await }
26//!     async fn websocket_close(&self, ws: WebSocket, _c: usize, _r: String, _w: bool)
27//!         -> Result<()> { self.driver.close(&self.state, &ws).await }
28//!     async fn alarm(&self) -> Result<Response> { self.driver.alarm(&self.state).await }
29//! }
30//! ```
31//!
32//! **Identity is a socket tag, and that is what makes hibernation work.** An
33//! idle room costs nothing because the runtime evicts the object and keeps only
34//! the sockets; anything the driver held in memory is gone by the next message.
35//! Tags survive that, so the member a socket belongs to is stored as its tag
36//! when it is accepted and read back out of the socket on every event. It is
37//! also what `send(member_id)` uses, through `get_websockets_with_tag`.
38//!
39//! **The driver never verifies a token.** `upgrade` takes a [`Member`] the
40//! venture has already established from the request, because verification is
41//! the venture's — it holds the signer, and the auth client, and knows which of
42//! the two this room trusts. A driver that accepted a raw token would be
43//! guessing at that on the venture's behalf.
44
45use std::collections::BTreeSet;
46use std::sync::Arc;
47use std::time::Duration;
48
49use async_trait::async_trait;
50use cratefield_core::{Member, RealtimeError, RoomContext, RoomHandler};
51use worker::send::IntoSendFuture;
52use worker::{
53    Response, Result as WorkerResult, State, WebSocket, WebSocketIncomingMessage, WebSocketPair,
54};
55
56/// Forwards a Durable Object's four entry points to a module's [`RoomHandler`].
57pub struct RoomDriver {
58    handler: Arc<dyn RoomHandler>,
59}
60
61impl RoomDriver {
62    #[must_use]
63    pub fn new(handler: Arc<dyn RoomHandler>) -> Self {
64        Self { handler }
65    }
66
67    /// The path prefix this driver's handler declares, so a venture mounts the
68    /// upgrade where the module says rather than where the venture guessed.
69    ///
70    /// `RoomHandler::route()` documents itself as "the path prefix the runtime
71    /// mounts the upgrade endpoint under", and nothing read it: a module that
72    /// overrode it compiled, documented its own prefix, and never received an
73    /// upgrade. One place now decides.
74    #[must_use]
75    pub fn route(&self) -> &'static str {
76        self.handler.route()
77    }
78
79    /// Accept the upgrade and join `member` to the room.
80    ///
81    /// `accept_websocket_with_tags` rather than `accept_web_socket`: the tag is
82    /// the member id, and it is the only thing that survives hibernation.
83    ///
84    /// # Errors
85    /// When the socket pair cannot be created, or the handler refuses the join —
86    /// in which case the socket is closed before the error is returned, because
87    /// the client is already holding one end of an open pair.
88    pub async fn upgrade(&self, state: &State, member: &Member) -> WorkerResult<Response> {
89        let pair = WebSocketPair::new()?;
90        state.accept_websocket_with_tags(&pair.server, &[member.id.as_str()]);
91
92        let ctx = DurableRoomContext::new(state);
93        if let Err(error) = self.handler.on_join(&ctx, member).await {
94            // The socket is already accepted, so a handler that refuses the join
95            // has to be answered by closing it rather than by failing the
96            // upgrade: the client is holding one end of an open pair.
97            let _ = pair.server.close(Some(1011), Some(error.to_string()));
98            return Err(worker::Error::RustError(error.to_string()));
99        }
100        Response::from_websocket(pair.client)
101    }
102
103    /// A frame arrived. Text and binary both reach the handler as bytes; the
104    /// module owns the protocol, so the framing distinction is not its business.
105    ///
106    /// # Errors
107    /// When the module's handler returns one. A frame from a socket carrying no
108    /// member tag is dropped rather than failed: it is one this driver never
109    /// accepted.
110    pub async fn message(
111        &self,
112        state: &State,
113        ws: &WebSocket,
114        message: WebSocketIncomingMessage,
115    ) -> WorkerResult<()> {
116        let Some(member) = member_of(state, ws) else {
117            // A socket with no tag is one this driver never accepted. Dropping
118            // the frame is `RealtimeError::NotAMember` by another name.
119            return Ok(());
120        };
121        let bytes = match message {
122            WebSocketIncomingMessage::String(text) => text.into_bytes(),
123            WebSocketIncomingMessage::Binary(bytes) => bytes,
124        };
125        let ctx = DurableRoomContext::new(state);
126        self.handler
127            .on_message(&ctx, &member, &bytes)
128            .await
129            .map_err(|error| into_worker(&error))
130    }
131
132    /// The socket closed, by either end.
133    ///
134    /// # Errors
135    /// When the module's handler returns one.
136    pub async fn close(&self, state: &State, ws: &WebSocket) -> WorkerResult<()> {
137        let Some(member) = member_of(state, ws) else {
138            return Ok(());
139        };
140        let ctx = DurableRoomContext::new(state);
141        let outcome = self.handler.on_leave(&ctx, &member).await;
142
143        // **An empty room must stop waking up.** The native adapter drops the
144        // room and its pending alarm when the last member goes; without this the
145        // object is woken at the scheduled time to broadcast to nobody, against
146        // the "an idle room costs nothing" claim this module is built on. The
147        // closing socket is still in `get_websockets()` here, so one remaining
148        // socket means only this one.
149        if state.get_websockets().len() <= 1 {
150            let _ = state.storage().delete_alarm().into_send().await;
151        }
152        outcome.map_err(|error| into_worker(&error))
153    }
154
155    /// A socket failed, which is a leave by another name.
156    ///
157    /// **The class must forward this or the room dies.** `#[durable_object]`
158    /// binds every handler including `webSocketError`, and the trait's default
159    /// body is `unimplemented!()` — so one member's phone dropping off mid-
160    /// session panics the object and takes every other socket in the room with
161    /// it. It is also the path where `on_leave` would otherwise never run.
162    ///
163    /// # Errors
164    /// When the module's handler returns one.
165    pub async fn error(&self, state: &State, ws: &WebSocket) -> WorkerResult<()> {
166        self.close(state, ws).await
167    }
168
169    /// The shared clock ticked.
170    ///
171    /// # Errors
172    /// When the module's handler returns one.
173    pub async fn alarm(&self, state: &State) -> WorkerResult<Response> {
174        let ctx = DurableRoomContext::new(state);
175        // **Logged, not returned.** Cloudflare retries an alarm handler that
176        // fails, with backoff, up to several times — so propagating a transient
177        // error turns one `set_alarm` into a handful of ticks, which is the one
178        // invariant this port documents about alarms and the one the smoke test
179        // asserts. A tick that failed is a tick that failed.
180        if let Err(error) = self.handler.on_alarm(&ctx).await {
181            tracing::warn!(%error, "room alarm handler failed");
182        }
183        Response::empty()
184    }
185}
186
187/// The member a socket belongs to, from its tag.
188fn member_of(state: &State, ws: &WebSocket) -> Option<Member> {
189    state.get_tags(ws).into_iter().next().map(Member::new)
190}
191
192fn into_worker(error: &RealtimeError) -> worker::Error {
193    worker::Error::RustError(error.to_string())
194}
195
196/// [`RoomContext`] over a Durable Object's live sockets.
197///
198/// Borrows the state rather than owning anything: every call reads the sockets
199/// back out of the object, because between two events the object may have
200/// hibernated and any list this held would name sockets that no longer exist.
201struct DurableRoomContext<'a> {
202    state: &'a State,
203    /// Captured once. `State::id().name()` allocates a fresh `String` per call,
204    /// and an earlier version returned `.leak()` of it — a permanent allocation
205    /// every time a handler asked which room it was in, which for a handler that
206    /// logs the room on each frame is a Durable Object that grows until the
207    /// isolate kills it.
208    room_id: String,
209}
210
211impl<'a> DurableRoomContext<'a> {
212    fn new(state: &'a State) -> Self {
213        // **A Durable Object does not always know its own name.** `name()` is
214        // populated only when the runtime chose to carry it through, and under
215        // `wrangler dev` it comes back `None` — which the first version turned
216        // into an empty string, so `room_id()` answered "" and every handler
217        // that logged or keyed on it was keying on nothing. The hex id is always
218        // there and is just as unique per room; the name is nicer when it
219        // exists. Found by the smoke test, which is the only thing that can run
220        // this code at all.
221        let id = state.id();
222        let room_id = id.name().unwrap_or_else(|| id.to_string());
223        Self { state, room_id }
224    }
225}
226
227#[async_trait]
228impl RoomContext for DurableRoomContext<'_> {
229    fn room_id(&self) -> &str {
230        // The object *is* the room: a Durable Object id is the room id, and the
231        // venture routed the request to this object by name. The driver is
232        // inside one room and has no second one to confuse it with.
233        &self.room_id
234    }
235
236    async fn members(&self) -> Vec<Member> {
237        // **By member, not by socket.** One person with a phone and a tablet is
238        // one member; the native adapter keys its room by member id, so
239        // returning a multiset here would make the same port answer differently
240        // on the two runtimes — and a handler capping a room at twenty would
241        // turn away the eleventh person.
242        let ids: BTreeSet<String> = self
243            .state
244            .get_websockets()
245            .iter()
246            .filter_map(|ws| member_of(self.state, ws).map(|member| member.id))
247            .collect();
248        ids.into_iter().map(Member::new).collect()
249    }
250
251    async fn broadcast(&self, message: &[u8]) -> Result<(), RealtimeError> {
252        for ws in self.state.get_websockets() {
253            // One socket that will not take a frame does not stop the others:
254            // a broadcast to a room where one phone has gone into a tunnel has
255            // still reached everybody it could.
256            let _ = ws.send_with_bytes(message);
257        }
258        Ok(())
259    }
260
261    async fn send(&self, member_id: &str, message: &[u8]) -> Result<(), RealtimeError> {
262        // **The same contract as native, deliberately.** `InProcessRealtime`
263        // answers `Ok(())` for a member who is not there, and a port that
264        // returned an error on one runtime and success on the other would make
265        // every handler wrong on one of them — the divergence this port has no
266        // shared suite to catch.
267        //
268        // And one socket that will not take a frame does not stop the others,
269        // for the reason `broadcast` gives: a member whose phone is in a tunnel
270        // still has a tablet that is not.
271        for ws in self.state.get_websockets_with_tag(member_id) {
272            let _ = ws.send_with_bytes(message);
273        }
274        Ok(())
275    }
276
277    async fn set_alarm(&self, delay: Duration) -> Result<(), RealtimeError> {
278        // `set_alarm` returns a JS promise, whose future is `!Send`, and
279        // `RoomContext` is `Send + Sync` because a module's handler is. Workers
280        // is single-threaded, which is what makes the bridge sound rather than a
281        // hole — the same `worker::send` helper `D1Database` uses, so this crate
282        // still needs no `unsafe` under its `forbid(unsafe_code)`.
283        self.state
284            .storage()
285            .set_alarm(delay)
286            .into_send()
287            .await
288            .map_err(|error| RealtimeError::Operation(error.to_string()))
289    }
290}