Skip to main content

liminal_sdk/remote/
websocket.rs

1//! WebSocket transport for the remote SDK (LP-WS-TRANSPORT R2/R3, client legs).
2//!
3//! Layering, bottom to top:
4//!
5//! - [`core`] — the transport-neutral, `no_std + alloc`, event-driven liminal
6//!   driver (R3.1). Closed socket events in, closed commands out; owns
7//!   canonical-frame validation and in-flight wire correlation.
8//! - [`binding`] — the R2.2 conduit that passes socket facts into the landed
9//!   client unit (`liminal-protocol`) as typed fates and returns
10//!   aggregate-made decisions; no reconnect, retry, replay, or timer policy
11//!   lives outside the aggregate.
12//! - The blocking std adapter, connection, and subscription stream (R2.1),
13//!   which drive the same driver commands with synchronous `tungstenite`,
14//!   matching the SDK's synchronous model.
15//! - [`web_socket`] — the browser leg (R3.2): the platform-neutral F5 mirror
16//!   layer plus the `web-sys` shim behind the `browser` feature on wasm32,
17//!   binding the same driver to browser callbacks.
18//!
19//! The transport carries the canonical liminal wire protocol: one encoded
20//! frame is exactly one binary WebSocket message, encoded and decoded by
21//! `liminal::protocol` — there is no WS-specific codec and no protocol
22//! translation.
23
24pub mod binding;
25pub mod core;
26pub mod web_socket;
27
28#[cfg(feature = "std")]
29mod connection;
30#[cfg(feature = "std")]
31mod participant;
32#[cfg(feature = "std")]
33mod std_socket;
34#[cfg(feature = "std")]
35mod subscription;
36
37pub use binding::{
38    AttemptFateOutcome, AttemptFateRefusal, DetachLossOutcome, LossRecordOutcome,
39    LossRecordRefusal, OpenRequestDecision, OpenRequestRefusal, WebSocketAuthorityBinding,
40};
41pub use core::{
42    CommandRefusal, DriverOutput, DriverPhase, DriverStep, EventRefusal, FrameCorrelation,
43    FrameViolation, PostTerminalEvent, ResponseExpectation, SocketCommand, SocketEvent,
44    SocketFailure, TransportTerminal, WebSocketFrameDriver,
45};
46#[cfg(feature = "std")]
47pub use subscription::{WebSocketDeliveredMessage, WebSocketSubscriptionStream};
48
49use alloc::format;
50
51use liminal_protocol::wire::FRAME_MAX;
52
53use crate::SdkError;
54
55/// The F2 reassembly bound: the active liminal frame bound, derived from the
56/// protocol's named product limit [`FRAME_MAX`] (ten-byte header plus the
57/// generic `u32` payload ceiling).
58///
59/// Both `max_message_size` and `max_frame_size` of the client WebSocket are
60/// pinned to this exact value, so an oversize-declared message fails at the
61/// pinned bound from its declared length — never after allocation of the
62/// library's 64 MiB default buffer, and never at a WebSocket-invented limit
63/// tighter than what the same frame would be allowed over TCP.
64///
65/// # Errors
66///
67/// Returns [`SdkError::Protocol`] when the build target's `usize` cannot
68/// represent the bound (a 32-bit target). Refusing to connect is the only
69/// honest option: silently clamping would change which canonical frames the
70/// transport admits.
71pub fn liminal_ws_message_bound() -> Result<usize, SdkError> {
72    usize::try_from(FRAME_MAX).map_err(|_| SdkError::Protocol {
73        description: format!(
74            "websocket transport cannot start: this target's usize cannot represent the \
75             liminal frame bound of {FRAME_MAX} bytes"
76        ),
77    })
78}
79
80/// Builds a connection error with the given description.
81#[cfg(feature = "std")]
82pub(crate) fn connection_error(description: &str) -> SdkError {
83    use alloc::string::ToString;
84    SdkError::Connection {
85        description: description.to_string(),
86    }
87}
88
89/// Encodes one canonical frame into its exact byte image.
90#[cfg(feature = "std")]
91fn encode_frame(frame: &liminal::protocol::Frame) -> Result<alloc::vec::Vec<u8>, SdkError> {
92    use liminal::protocol::{encode, encoded_len};
93    let len = encoded_len(frame).map_err(|error| SdkError::Protocol {
94        description: format!("wire codec error: {error}"),
95    })?;
96    let mut bytes = alloc::vec![0_u8; len];
97    let written = encode(frame, &mut bytes).map_err(|error| SdkError::Protocol {
98        description: format!("wire codec error: {error}"),
99    })?;
100    if written != bytes.len() {
101        return Err(SdkError::Protocol {
102            description: "wire encoder reported an invalid byte count".to_string(),
103        });
104    }
105    Ok(bytes)
106}
107
108#[cfg(feature = "std")]
109mod transport {
110    //! The [`RemoteTransport`] implementation over one [`WsConnection`].
111
112    use alloc::format;
113    use alloc::string::ToString;
114    use alloc::sync::Arc;
115    use alloc::vec::Vec;
116    use core::fmt;
117
118    use liminal::protocol::{
119        CausalContext, Frame, MessageEnvelope, PUBLISH_DELIVERED_FLAG,
120        PUBLISH_IDEMPOTENCY_KEY_FLAG, SchemaId,
121    };
122    use liminal_protocol::outcome::ReconnectState;
123    use spin::Mutex;
124
125    use crate::remote::ServerAddress;
126    use crate::remote::participant::ParticipantResponseProvenance;
127    use crate::remote::protocol::{
128        ParticipantRemoteTransport, ParticipantTransportFrame, RemoteTransport,
129        WireConversationRequest, WirePublishRequest, WireResumeRequest, WireSubscribeRequest,
130    };
131    use crate::{DeliveryAck, PressureResponse, SdkError};
132
133    use super::connection::WsConnection;
134    use super::liminal_ws_message_bound;
135
136    /// Application stream id used for non-subscription application frames.
137    const APPLICATION_STREAM_ID: u32 = 1;
138    /// In-flight credit advertised on subscribe; one keeps strict pacing.
139    const DEFAULT_MAX_IN_FLIGHT: u32 = 1;
140    /// Schema id used for payloads whose schema is not carried on the wire.
141    const SCHEMALESS_SCHEMA: &[u8] = &[];
142
143    /// Real WebSocket transport that exchanges canonical wire frames with a
144    /// liminal server over the sibling WebSocket acceptor.
145    ///
146    /// The frame construction and response mapping deliberately mirror the
147    /// TCP transport line for line; the cross-transport byte-identity and
148    /// behavioral parity tests pin the two implementations together so they
149    /// cannot drift apart silently.
150    pub struct WebSocketRemoteTransport {
151        connection: Arc<Mutex<WsConnection>>,
152    }
153
154    impl fmt::Debug for WebSocketRemoteTransport {
155        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
156            formatter
157                .debug_struct("WebSocketRemoteTransport")
158                .finish_non_exhaustive()
159        }
160    }
161
162    impl WebSocketRemoteTransport {
163        /// Connects to the `ws://` server address, completes the WebSocket
164        /// upgrade and liminal handshake, and returns a ready transport.
165        ///
166        /// # Errors
167        ///
168        /// Returns [`SdkError::Connection`] when the address is not a usable
169        /// `ws://` URL, the client unit refuses the open, the socket cannot
170        /// be established, or the handshake is rejected, and
171        /// [`SdkError::Protocol`] when frames cannot be encoded or decoded.
172        pub fn connect(server_address: &ServerAddress) -> Result<Self, SdkError> {
173            Self::connect_with_auth(server_address, &[])
174        }
175
176        /// Connects and handshakes carrying `auth_token`, for a server gated
177        /// by an `[auth]` section. Additive to [`connect`]; an empty token is
178        /// equivalent to it.
179        ///
180        /// # Errors
181        ///
182        /// Returns [`SdkError::Connection`] when the connection cannot be
183        /// established or the token is rejected, and [`SdkError::Protocol`]
184        /// when the handshake frames cannot be encoded or sent.
185        ///
186        /// [`connect`]: Self::connect
187        pub fn connect_with_auth(
188            server_address: &ServerAddress,
189            auth_token: &[u8],
190        ) -> Result<Self, SdkError> {
191            let bound = liminal_ws_message_bound()?;
192            let connection = WsConnection::connect(server_address.as_str(), auth_token, bound)?;
193            Ok(Self {
194                connection: Arc::new(Mutex::new(connection)),
195            })
196        }
197
198        /// Performs one authorized reconnect open through the client unit's
199        /// typed permit path (R2.2): a permit retained from the established
200        /// loss — or a fresh explicit caller action — authorizes exactly one
201        /// real open. There is no automatic retry and no timer.
202        ///
203        /// # Errors
204        ///
205        /// Returns [`SdkError::Connection`] when the transport is still
206        /// connected, the client unit refuses the open, or the open fails
207        /// (parking the aggregate without retry authority).
208        pub fn reconnect(&self) -> Result<(), SdkError> {
209            self.connection.lock().reconnect()
210        }
211
212        /// Reports the client unit's reconnect state for this transport.
213        #[must_use]
214        pub fn reconnect_state(&self) -> ReconnectState {
215            self.connection.lock().reconnect_state()
216        }
217
218        fn round_trip(&self, request: &Frame) -> Result<Frame, SdkError> {
219            let mut connection = self.connection.lock();
220            connection.round_trip(request)
221        }
222    }
223
224    impl ParticipantRemoteTransport for WebSocketRemoteTransport {
225        fn send_participant(
226            &self,
227            _server_address: &ServerAddress,
228            request: &liminal_protocol::wire::ClientRequest,
229        ) -> Result<ParticipantResponseProvenance, SdkError> {
230            self.connection.lock().send_participant(request)
231        }
232
233        fn receive_participant(
234            &self,
235            _server_address: &ServerAddress,
236        ) -> Result<ParticipantTransportFrame, SdkError> {
237            let (frame, provenance) = self.connection.lock().receive_participant()?;
238            Ok(ParticipantTransportFrame { frame, provenance })
239        }
240
241        fn reconnect_participant(
242            &self,
243            _server_address: &ServerAddress,
244        ) -> Result<ParticipantResponseProvenance, SdkError> {
245            self.connection.lock().reconnect_participant()
246        }
247    }
248
249    impl RemoteTransport for WebSocketRemoteTransport {
250        fn publish(
251            &self,
252            _server_address: &ServerAddress,
253            request: &WirePublishRequest,
254        ) -> Result<PressureResponse, SdkError> {
255            let frame = build_publish_frame(request);
256            let response = self.round_trip(&frame)?;
257            publish_response(response)
258        }
259
260        fn publish_with_delivery(
261            &self,
262            _server_address: &ServerAddress,
263            request: &WirePublishRequest,
264        ) -> Result<DeliveryAck, SdkError> {
265            let frame = build_publish_frame(request);
266            let response = self.round_trip(&frame)?;
267            publish_delivery_response(response)
268        }
269
270        /// Subscribes over the shared request/response connection, with the
271        /// same v1 pooled-subscribe caveat as the TCP transport: channel
272        /// deliveries are consumed through a dedicated
273        /// [`WebSocketSubscriptionStream`](super::WebSocketSubscriptionStream),
274        /// and the pooled subscribe serves as the delivery-ack signal.
275        fn subscribe(
276            &self,
277            _server_address: &ServerAddress,
278            request: &WireSubscribeRequest,
279        ) -> Result<(), SdkError> {
280            let frame = Frame::Subscribe {
281                flags: 0,
282                stream_id: request.stream_id(),
283                channel: request.channel().to_string(),
284                // An empty accepted-schema list lets the server select the
285                // channel's configured schema (the negotiation contract).
286                accepted_schemas: Vec::new(),
287                max_in_flight: DEFAULT_MAX_IN_FLIGHT,
288            };
289            let response = self.round_trip(&frame)?;
290            subscribe_response(response)
291        }
292
293        fn send_conversation(
294            &self,
295            _server_address: &ServerAddress,
296            request: &WireConversationRequest,
297        ) -> Result<(), SdkError> {
298            let conversation_label = request.conversation_id().as_str();
299            let conversation_id = conversation_wire_id(conversation_label);
300            let envelope = build_envelope(SCHEMALESS_SCHEMA, request.payload());
301            let mut connection = self.connection.lock();
302            connection.send_conversation_message(conversation_id, conversation_label, envelope)
303        }
304
305        fn request_reply_conversation(
306            &self,
307            _server_address: &ServerAddress,
308            request: &WireConversationRequest,
309        ) -> Result<Vec<u8>, SdkError> {
310            let conversation_label = request.conversation_id().as_str();
311            let conversation_id = conversation_wire_id(conversation_label);
312            let envelope = build_envelope(SCHEMALESS_SCHEMA, request.payload());
313            let mut connection = self.connection.lock();
314            connection.conversation_request_reply(conversation_id, conversation_label, envelope)
315        }
316
317        fn resume(
318            &self,
319            _server_address: &ServerAddress,
320            request: &WireResumeRequest,
321        ) -> Result<(), SdkError> {
322            // The wire protocol has no resume frame (the server replays a
323            // subscription only when the SDK re-issues its Subscribe), so
324            // this transport surfaces the same typed refusal as TCP instead
325            // of reporting success while dropping the resume intent.
326            let _ = (request.subscription_id(), request.resume_from_sequence());
327            Err(SdkError::Protocol {
328                description:
329                    "resume is not yet supported over the WebSocket transport; re-subscribe to \
330                     trigger server replay"
331                        .to_string(),
332            })
333        }
334    }
335
336    fn build_envelope(schema_bytes: &[u8], payload: &[u8]) -> MessageEnvelope {
337        MessageEnvelope::new(
338            schema_id_from_bytes(schema_bytes),
339            CausalContext::independent(),
340            payload.to_vec(),
341        )
342    }
343
344    /// Derives a stable 32-byte schema id from arbitrary schema bytes via
345    /// FNV-1a (byte-identical to the TCP transport's derivation, pinned by
346    /// the cross-transport byte-identity test).
347    fn schema_id_from_bytes(schema_bytes: &[u8]) -> SchemaId {
348        let mut id = [0_u8; SchemaId::WIRE_LEN];
349        let mut hash = fnv1a(schema_bytes).to_be_bytes();
350        for (index, slot) in id.iter_mut().enumerate() {
351            *slot = hash[index % hash.len()];
352            if index % hash.len() == hash.len() - 1 {
353                hash = fnv1a(&hash).to_be_bytes();
354            }
355        }
356        SchemaId::new(id)
357    }
358
359    fn conversation_wire_id(conversation_id: &str) -> u64 {
360        fnv1a(conversation_id.as_bytes())
361    }
362
363    /// FNV-1a 64-bit hash, used only for deterministic wire-id derivation.
364    fn fnv1a(bytes: &[u8]) -> u64 {
365        const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
366        const PRIME: u64 = 0x0000_0100_0000_01b3;
367        let mut hash = OFFSET_BASIS;
368        for byte in bytes {
369            hash ^= u64::from(*byte);
370            hash = hash.wrapping_mul(PRIME);
371        }
372        hash
373    }
374
375    /// Builds the wire `Publish` frame, attaching the idempotency key (and
376    /// its flag) only when the request carries one, keeping a no-key publish
377    /// byte-identical to the TCP transport's layout.
378    fn build_publish_frame(request: &WirePublishRequest) -> Frame {
379        let envelope = build_envelope(request.schema().schema.as_ref(), request.payload());
380        let flags = match request.idempotency_key() {
381            Some(_) => PUBLISH_IDEMPOTENCY_KEY_FLAG,
382            None => 0,
383        };
384        Frame::Publish {
385            flags,
386            stream_id: APPLICATION_STREAM_ID,
387            channel: request.channel().to_string(),
388            envelope,
389            idempotency_key: request.idempotency_key().map(ToString::to_string),
390        }
391    }
392
393    fn publish_response(frame: Frame) -> Result<PressureResponse, SdkError> {
394        match frame {
395            Frame::PublishAck { .. } => Ok(PressureResponse::Accept),
396            Frame::PublishError {
397                reason_code,
398                message,
399                ..
400            } => Err(SdkError::Backpressure {
401                reason: format!(
402                    "server rejected publish (reason {reason_code}): {}",
403                    message.unwrap_or_else(|| "no detail".to_string())
404                ),
405            }),
406            other => Err(super::connection::unexpected_response("PublishAck", &other)),
407        }
408    }
409
410    /// Maps a publish ack into a genuine delivery ack via the
411    /// `PUBLISH_DELIVERED_FLAG` bit, exactly like the TCP transport.
412    fn publish_delivery_response(frame: Frame) -> Result<DeliveryAck, SdkError> {
413        match frame {
414            Frame::PublishAck { flags, .. } => {
415                let accepted = flags & PUBLISH_DELIVERED_FLAG != 0;
416                Ok(DeliveryAck::new(PressureResponse::Accept, accepted))
417            }
418            Frame::PublishError {
419                reason_code,
420                message,
421                ..
422            } => Err(SdkError::Backpressure {
423                reason: format!(
424                    "server rejected publish (reason {reason_code}): {}",
425                    message.unwrap_or_else(|| "no detail".to_string())
426                ),
427            }),
428            other => Err(super::connection::unexpected_response("PublishAck", &other)),
429        }
430    }
431
432    fn subscribe_response(frame: Frame) -> Result<(), SdkError> {
433        match frame {
434            Frame::SubscribeAck { .. } => Ok(()),
435            Frame::SubscribeError {
436                reason_code,
437                message,
438                ..
439            } => Err(SdkError::Protocol {
440                description: format!(
441                    "server rejected subscribe (reason {reason_code}): {}",
442                    message.unwrap_or_else(|| "no detail".to_string())
443                ),
444            }),
445            other => Err(super::connection::unexpected_response(
446                "SubscribeAck",
447                &other,
448            )),
449        }
450    }
451}
452
453#[cfg(feature = "std")]
454pub use transport::WebSocketRemoteTransport;