Skip to main content

ocre/runtime/
realtime.rs

1use axum::response::Response;
2use wasm_bindgen::JsValue;
3use worker::{Env, Headers, Method, Request, RequestInit, Stub, send::SendFuture};
4
5use super::Ctx;
6use crate::{
7    Error, Result,
8    realtime::{
9        CHANNELS_BINDING, LOG_PREFIX, SUBSCRIBER_HEADER, WebSocketUpgrade, channel_error, missing_binding, record,
10    },
11};
12
13// `#[durable_object]` generates public wasm-bindgen glue (constructor and runtime callbacks) inside
14// a `const _` block, which cannot carry docs and which an `allow` on the struct does not reach.
15#[allow(missing_docs)]
16mod channel;
17
18pub use channel::OcreChannel;
19
20/// Address of requests from the Worker to a channel object; only the method matters.
21const CHANNEL_URL: &str = "https://ocre-channel/";
22
23/// The channel object for `channel`.
24fn stub(env: &Env, channel: &str) -> Result<Stub> {
25    let namespace = env.durable_object(CHANNELS_BINDING).map_err(|err| missing_binding(&err.to_string()))?;
26    Ok(namespace.get_by_name(channel)?)
27}
28
29/// Sends `message` (an HTML fragment or JSON text) to every browser connected to `channel`.
30///
31/// Returns once the channel's [`OcreChannel`] object has sent it. Build HTML
32/// messages with [`prepend`](crate::realtime::prepend),
33/// [`append`](crate::realtime::append), [`update`](crate::realtime::update),
34/// [`remove`](crate::realtime::remove), or an element with an `id` that
35/// replaces the page element with that id; several can go in one message.
36/// Works from handlers and jobs. The returned future is `Send`, so axum
37/// handlers can await it.
38///
39/// Every failure is logged as `[ocre realtime] broadcast to <channel> failed:
40/// ...` and returned; callers that treat updates as best effort (the
41/// generated controllers do) can ignore it with `.ok()`.
42///
43/// Free plan: each call is one Durable Object request (100,000 a day), even
44/// when the channel has no subscribers, and one subrequest of the current
45/// request; the messages to browsers are free.
46///
47/// # Errors
48///
49/// - [`Error::Internal`] when `channel` is not a valid name (1 to
50///   [`MAX_CHANNEL_LEN`](crate::realtime::MAX_CHANNEL_LEN) ASCII letters,
51///   digits, `_`, `-`, `.` or `:`).
52/// - [`Error::Internal`] when the `CHANNELS` Durable Object binding is
53///   missing; the message names the cloudflare.config.ts entries to add.
54/// - [`Error::Internal`] when the channel object cannot be reached (e.g. the
55///   free-plan quota is exhausted) or answers with a non-200 status.
56///
57/// # Examples
58///
59/// ```no_run
60/// use axum::extract::{Path, State};
61/// use ocre::{Ctx, Result, realtime};
62///
63/// async fn destroy(State(ctx): State<Ctx>, Path(id): Path<i64>) -> Result<()> {
64///     // ... delete the post, then remove its row from every open index page:
65///     realtime::broadcast(&ctx, "posts", &realtime::remove(&format!("post_{id}"))).await.ok();
66///     Ok(())
67/// }
68/// ```
69pub fn broadcast(ctx: &Ctx, channel: &str, message: &str) -> impl Future<Output = Result<()>> + Send + use<> {
70    let env = ctx.env().clone();
71    let invalid = channel_error(channel);
72    let channel = channel.to_owned();
73    let message = message.to_owned();
74    SendFuture::new(async move {
75        let result = match invalid {
76            Some(invalid) => Err(Error::internal(invalid)),
77            None => send(&env, &channel, &message).await,
78        };
79        match &result {
80            Ok(()) => record(&channel, &message),
81            Err(err) => worker::console_error!("{LOG_PREFIX} broadcast to {channel} failed: {err}"),
82        }
83        result
84    })
85}
86
87async fn send(env: &Env, channel: &str, message: &str) -> Result<()> {
88    let mut init = RequestInit::new();
89    init.with_method(Method::Post).with_body(Some(JsValue::from_str(message)));
90    let mut response = stub(env, channel)?.fetch_with_request(Request::new_with_init(CHANNEL_URL, &init)?).await?;
91    match response.status_code() {
92        200 => Ok(()),
93        status => Err(Error::internal(format!("channel object answered {status}: {}", response.text().await?))),
94    }
95}
96
97impl WebSocketUpgrade {
98    /// Connects the browser to `channel`, returning the `101 Switching Protocols` response to send back.
99    ///
100    /// The handshake is forwarded to the channel's [`OcreChannel`] object,
101    /// which accepts the WebSocket (hibernating). Check who may listen before
102    /// calling it: list the allowed channels, or use a channel per record or
103    /// user (`post:12`). The returned future is `Send`.
104    ///
105    /// Free plan: one Durable Object request per connection and reconnection.
106    ///
107    /// # Errors
108    ///
109    /// - [`Error::BadRequest`] (400) when `channel` is not a valid name (1 to
110    ///   [`MAX_CHANNEL_LEN`](crate::realtime::MAX_CHANNEL_LEN) ASCII letters,
111    ///   digits, `_`, `-`, `.` or `:`).
112    /// - [`Error::Internal`] (500) when the `CHANNELS` Durable Object binding
113    ///   is missing (the message names the cloudflare.config.ts entries to add) or
114    ///   the channel object cannot be reached.
115    ///
116    /// # Examples
117    ///
118    /// ```no_run
119    /// use axum::{extract::{Path, State}, response::Response};
120    /// use ocre::{Ctx, Error, Result, realtime::WebSocketUpgrade};
121    ///
122    /// async fn connect(State(ctx): State<Ctx>, Path(channel): Path<String>, upgrade: WebSocketUpgrade)
123    ///     -> Result<Response> {
124    ///     match channel.as_str() {
125    ///         "posts" => {}
126    ///         _ => return Err(Error::NotFound),
127    ///     }
128    ///     upgrade.connect(&ctx, &channel).await
129    /// }
130    /// ```
131    pub fn connect(self, ctx: &Ctx, channel: &str) -> impl Future<Output = Result<Response>> + Send + use<> {
132        let env = ctx.env().clone();
133        let invalid = channel_error(channel);
134        let channel = channel.to_owned();
135        let subscriber = self.subscriber_header();
136        SendFuture::new(async move {
137            if let Some(invalid) = invalid {
138                return Err(Error::bad_request(invalid));
139            }
140            let headers = Headers::new();
141            headers.set("Upgrade", "websocket")?;
142            headers.set(SUBSCRIBER_HEADER, &subscriber?)?;
143            let mut init = RequestInit::new();
144            init.with_headers(headers);
145            let response =
146                stub(&env, &channel)?.fetch_with_request(Request::new_with_init(CHANNEL_URL, &init)?).await?;
147            Ok(Response::from(response))
148        })
149    }
150}