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