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}