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    use core::time::Duration;
118
119    use liminal::protocol::{
120        CausalContext, Frame, MessageEnvelope, PUBLISH_DELIVERED_FLAG,
121        PUBLISH_IDEMPOTENCY_KEY_FLAG, SchemaId,
122    };
123    use liminal_protocol::outcome::ReconnectState;
124    use spin::Mutex;
125
126    use crate::remote::ServerAddress;
127    use crate::remote::participant::ParticipantResponseProvenance;
128    use crate::remote::protocol::{
129        ParticipantRemoteTransport, ParticipantTransportFrame, RemoteTransport,
130        WireConversationRequest, WirePublishRequest, WireResumeRequest, WireSubscribeRequest,
131    };
132    use crate::{DeliveryAck, PressureResponse, SdkError};
133
134    use super::connection::WsConnection;
135    use super::liminal_ws_message_bound;
136
137    /// Application stream id used for non-subscription application frames.
138    const APPLICATION_STREAM_ID: u32 = 1;
139    /// In-flight credit advertised on subscribe; one keeps strict pacing.
140    const DEFAULT_MAX_IN_FLIGHT: u32 = 1;
141    /// Schema id used for payloads whose schema is not carried on the wire.
142    const SCHEMALESS_SCHEMA: &[u8] = &[];
143
144    /// Real WebSocket transport that exchanges canonical wire frames with a
145    /// liminal server over the sibling WebSocket acceptor.
146    ///
147    /// The frame construction and response mapping deliberately mirror the
148    /// TCP transport line for line; the cross-transport byte-identity and
149    /// behavioral parity tests pin the two implementations together so they
150    /// cannot drift apart silently.
151    pub struct WebSocketRemoteTransport {
152        connection: Arc<Mutex<WsConnection>>,
153    }
154
155    impl fmt::Debug for WebSocketRemoteTransport {
156        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
157            formatter
158                .debug_struct("WebSocketRemoteTransport")
159                .finish_non_exhaustive()
160        }
161    }
162
163    impl WebSocketRemoteTransport {
164        /// Connects to the `ws://` server address, completes the WebSocket
165        /// upgrade and liminal handshake, and returns a ready transport.
166        ///
167        /// # Errors
168        ///
169        /// Returns [`SdkError::Connection`] when the address is not a usable
170        /// `ws://` URL, the client unit refuses the open, the socket cannot
171        /// be established, or the handshake is rejected, and
172        /// [`SdkError::Protocol`] when frames cannot be encoded or decoded.
173        pub fn connect(server_address: &ServerAddress) -> Result<Self, SdkError> {
174            Self::connect_with_auth(server_address, &[])
175        }
176
177        /// Connects and handshakes carrying `auth_token`, for a server gated
178        /// by an `[auth]` section. Additive to [`connect`]; an empty token is
179        /// equivalent to it.
180        ///
181        /// # Errors
182        ///
183        /// Returns [`SdkError::Connection`] when the connection cannot be
184        /// established or the token is rejected, and [`SdkError::Protocol`]
185        /// when the handshake frames cannot be encoded or sent.
186        ///
187        /// [`connect`]: Self::connect
188        pub fn connect_with_auth(
189            server_address: &ServerAddress,
190            auth_token: &[u8],
191        ) -> Result<Self, SdkError> {
192            let bound = liminal_ws_message_bound()?;
193            let connection = WsConnection::connect(server_address.as_str(), auth_token, bound)?;
194            Ok(Self {
195                connection: Arc::new(Mutex::new(connection)),
196            })
197        }
198
199        /// Performs one authorized reconnect open through the client unit's
200        /// typed permit path (R2.2): a permit retained from the established
201        /// loss — or a fresh explicit caller action — authorizes exactly one
202        /// real open. There is no automatic retry and no timer.
203        ///
204        /// # Errors
205        ///
206        /// Returns [`SdkError::Connection`] when the transport is still
207        /// connected, the client unit refuses the open, or the open fails
208        /// (parking the aggregate without retry authority).
209        pub fn reconnect(&self) -> Result<(), SdkError> {
210            self.connection.lock().reconnect()
211        }
212
213        /// Reports the client unit's reconnect state for this transport.
214        #[must_use]
215        pub fn reconnect_state(&self) -> ReconnectState {
216            self.connection.lock().reconnect_state()
217        }
218
219        fn round_trip(&self, request: &Frame) -> Result<Frame, SdkError> {
220            let mut connection = self.connection.lock();
221            connection.round_trip(request)
222        }
223    }
224
225    impl ParticipantRemoteTransport for WebSocketRemoteTransport {
226        fn send_participant(
227            &self,
228            _server_address: &ServerAddress,
229            request: &liminal_protocol::wire::ClientRequest,
230        ) -> Result<ParticipantResponseProvenance, SdkError> {
231            self.connection.lock().send_participant(request)
232        }
233
234        fn receive_participant(
235            &self,
236            _server_address: &ServerAddress,
237        ) -> Result<ParticipantTransportFrame, SdkError> {
238            let (frame, provenance) = self.connection.lock().receive_participant()?;
239            Ok(ParticipantTransportFrame { frame, provenance })
240        }
241
242        fn receive_participant_within(
243            &self,
244            _server_address: &ServerAddress,
245            budget: Duration,
246        ) -> Result<Option<ParticipantTransportFrame>, SdkError> {
247            let Some((frame, provenance)) =
248                self.connection.lock().receive_participant_within(budget)?
249            else {
250                return Ok(None);
251            };
252            Ok(Some(ParticipantTransportFrame { frame, provenance }))
253        }
254
255        fn reconnect_participant(
256            &self,
257            _server_address: &ServerAddress,
258        ) -> Result<ParticipantResponseProvenance, SdkError> {
259            self.connection.lock().reconnect_participant()
260        }
261    }
262
263    impl RemoteTransport for WebSocketRemoteTransport {
264        fn publish(
265            &self,
266            _server_address: &ServerAddress,
267            request: &WirePublishRequest,
268        ) -> Result<PressureResponse, SdkError> {
269            let frame = build_publish_frame(request);
270            let response = self.round_trip(&frame)?;
271            publish_response(response)
272        }
273
274        fn publish_with_delivery(
275            &self,
276            _server_address: &ServerAddress,
277            request: &WirePublishRequest,
278        ) -> Result<DeliveryAck, SdkError> {
279            let frame = build_publish_frame(request);
280            let response = self.round_trip(&frame)?;
281            publish_delivery_response(response)
282        }
283
284        /// Subscribes over the shared request/response connection, with the
285        /// same v1 pooled-subscribe caveat as the TCP transport: channel
286        /// deliveries are consumed through a dedicated
287        /// [`WebSocketSubscriptionStream`](super::WebSocketSubscriptionStream),
288        /// and the pooled subscribe serves as the delivery-ack signal.
289        fn subscribe(
290            &self,
291            _server_address: &ServerAddress,
292            request: &WireSubscribeRequest,
293        ) -> Result<(), SdkError> {
294            let frame = Frame::Subscribe {
295                flags: 0,
296                stream_id: request.stream_id(),
297                channel: request.channel().to_string(),
298                // An empty accepted-schema list lets the server select the
299                // channel's configured schema (the negotiation contract).
300                accepted_schemas: Vec::new(),
301                max_in_flight: DEFAULT_MAX_IN_FLIGHT,
302            };
303            let response = self.round_trip(&frame)?;
304            subscribe_response(response)
305        }
306
307        fn send_conversation(
308            &self,
309            _server_address: &ServerAddress,
310            request: &WireConversationRequest,
311        ) -> Result<(), SdkError> {
312            let conversation_label = request.conversation_id().as_str();
313            let conversation_id = conversation_wire_id(conversation_label);
314            let envelope = build_envelope(SCHEMALESS_SCHEMA, request.payload());
315            let mut connection = self.connection.lock();
316            connection.send_conversation_message(conversation_id, conversation_label, envelope)
317        }
318
319        fn request_reply_conversation(
320            &self,
321            _server_address: &ServerAddress,
322            request: &WireConversationRequest,
323        ) -> Result<Vec<u8>, SdkError> {
324            let conversation_label = request.conversation_id().as_str();
325            let conversation_id = conversation_wire_id(conversation_label);
326            let envelope = build_envelope(SCHEMALESS_SCHEMA, request.payload());
327            let mut connection = self.connection.lock();
328            connection.conversation_request_reply(conversation_id, conversation_label, envelope)
329        }
330
331        fn resume(
332            &self,
333            _server_address: &ServerAddress,
334            request: &WireResumeRequest,
335        ) -> Result<(), SdkError> {
336            // The wire protocol has no resume frame (the server replays a
337            // subscription only when the SDK re-issues its Subscribe), so
338            // this transport surfaces the same typed refusal as TCP instead
339            // of reporting success while dropping the resume intent.
340            let _ = (request.subscription_id(), request.resume_from_sequence());
341            Err(SdkError::Protocol {
342                description:
343                    "resume is not yet supported over the WebSocket transport; re-subscribe to \
344                     trigger server replay"
345                        .to_string(),
346            })
347        }
348    }
349
350    fn build_envelope(schema_bytes: &[u8], payload: &[u8]) -> MessageEnvelope {
351        MessageEnvelope::new(
352            schema_id_from_bytes(schema_bytes),
353            CausalContext::independent(),
354            payload.to_vec(),
355        )
356    }
357
358    /// Derives a stable 32-byte schema id from arbitrary schema bytes via
359    /// FNV-1a (byte-identical to the TCP transport's derivation, pinned by
360    /// the cross-transport byte-identity test).
361    fn schema_id_from_bytes(schema_bytes: &[u8]) -> SchemaId {
362        let mut id = [0_u8; SchemaId::WIRE_LEN];
363        let mut hash = fnv1a(schema_bytes).to_be_bytes();
364        for (index, slot) in id.iter_mut().enumerate() {
365            *slot = hash[index % hash.len()];
366            if index % hash.len() == hash.len() - 1 {
367                hash = fnv1a(&hash).to_be_bytes();
368            }
369        }
370        SchemaId::new(id)
371    }
372
373    fn conversation_wire_id(conversation_id: &str) -> u64 {
374        fnv1a(conversation_id.as_bytes())
375    }
376
377    /// FNV-1a 64-bit hash, used only for deterministic wire-id derivation.
378    fn fnv1a(bytes: &[u8]) -> u64 {
379        const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
380        const PRIME: u64 = 0x0000_0100_0000_01b3;
381        let mut hash = OFFSET_BASIS;
382        for byte in bytes {
383            hash ^= u64::from(*byte);
384            hash = hash.wrapping_mul(PRIME);
385        }
386        hash
387    }
388
389    /// Builds the wire `Publish` frame, attaching the idempotency key (and
390    /// its flag) only when the request carries one, keeping a no-key publish
391    /// byte-identical to the TCP transport's layout.
392    fn build_publish_frame(request: &WirePublishRequest) -> Frame {
393        let envelope = build_envelope(request.schema().schema.as_ref(), request.payload());
394        let flags = match request.idempotency_key() {
395            Some(_) => PUBLISH_IDEMPOTENCY_KEY_FLAG,
396            None => 0,
397        };
398        Frame::Publish {
399            flags,
400            stream_id: APPLICATION_STREAM_ID,
401            channel: request.channel().to_string(),
402            envelope,
403            idempotency_key: request.idempotency_key().map(ToString::to_string),
404        }
405    }
406
407    fn publish_response(frame: Frame) -> Result<PressureResponse, SdkError> {
408        match frame {
409            Frame::PublishAck { .. } => Ok(PressureResponse::Accept),
410            Frame::PublishError {
411                reason_code,
412                message,
413                ..
414            } => Err(SdkError::Backpressure {
415                reason: format!(
416                    "server rejected publish (reason {reason_code}): {}",
417                    message.unwrap_or_else(|| "no detail".to_string())
418                ),
419            }),
420            other => Err(super::connection::unexpected_response("PublishAck", &other)),
421        }
422    }
423
424    /// Maps a publish ack into a genuine delivery ack via the
425    /// `PUBLISH_DELIVERED_FLAG` bit, exactly like the TCP transport.
426    fn publish_delivery_response(frame: Frame) -> Result<DeliveryAck, SdkError> {
427        match frame {
428            Frame::PublishAck { flags, .. } => {
429                let accepted = flags & PUBLISH_DELIVERED_FLAG != 0;
430                Ok(DeliveryAck::new(PressureResponse::Accept, accepted))
431            }
432            Frame::PublishError {
433                reason_code,
434                message,
435                ..
436            } => Err(SdkError::Backpressure {
437                reason: format!(
438                    "server rejected publish (reason {reason_code}): {}",
439                    message.unwrap_or_else(|| "no detail".to_string())
440                ),
441            }),
442            other => Err(super::connection::unexpected_response("PublishAck", &other)),
443        }
444    }
445
446    fn subscribe_response(frame: Frame) -> Result<(), SdkError> {
447        match frame {
448            Frame::SubscribeAck { .. } => Ok(()),
449            Frame::SubscribeError {
450                reason_code,
451                message,
452                ..
453            } => Err(SdkError::Protocol {
454                description: format!(
455                    "server rejected subscribe (reason {reason_code}): {}",
456                    message.unwrap_or_else(|| "no detail".to_string())
457                ),
458            }),
459            other => Err(super::connection::unexpected_response(
460                "SubscribeAck",
461                &other,
462            )),
463        }
464    }
465}
466
467#[cfg(feature = "std")]
468pub use transport::WebSocketRemoteTransport;