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