Skip to main content

liminal_sdk/
remote.rs

1mod config;
2mod handles;
3mod participant;
4mod protocol;
5#[cfg(feature = "std")]
6mod tcp;
7pub mod websocket;
8
9#[cfg(feature = "std")]
10pub use tcp::{
11    DeliveredMessage, FlushMode, FlushOutcome, OBSERVABILITY_CHANNEL, PublishRejection, PushClient,
12    PushWriter, PushedFrame, SubscriptionStream, TcpRemoteTransport,
13};
14#[cfg(feature = "std")]
15pub use websocket::{
16    WebSocketDeliveredMessage, WebSocketRemoteTransport, WebSocketSubscriptionStream,
17};
18
19pub use config::{SdkConfig, build_channel_handle, build_conversation_handle};
20pub use handles::{
21    RemoteChannelHandle, RemoteConversationHandle, RemoteParticipantHandle, SdkChannelHandle,
22    SdkConversationHandle,
23};
24pub use participant::{
25    ParticipantResponseProvenance, ParticipantResumeStore, RemoteDetachReplayOutcome,
26    RemoteExpectedOperationRecovery, RemoteLostOperationResolution, RemoteLostReconnectResolution,
27    RemoteOperationRecordOutcome, RemoteOperationTransportFate, RemoteParticipantError,
28    RemoteParticipantInbound, RemoteParticipantOperation, RemoteParticipantSendOutcome,
29    RemoteReconnectAttemptOutcome, RemoteReconnectPermit, RemoteReconnectPermitOutcome,
30    RemoteReconnectPermitRecovery, RemoteReplayApplyOutcome, RemoteTransportLossOutcome,
31};
32
33#[cfg(test)]
34mod tests;
35
36use alloc::string::{String, ToString};
37use alloc::sync::Arc;
38
39use crate::connection::ConnectionPoolConfig;
40use crate::{ConversationId, SdkError};
41
42use self::protocol::{ProtocolRemoteTransport, RemoteTransport};
43
44/// The one named deadline every reader gives a synchronous control-frame reply.
45///
46/// Five seconds — the estate's already-ratified value, generalized rather than
47/// re-chosen: the WebSocket socket layer and the TCP subscription reader both
48/// already read `Duration::from_secs(5)`, and the TCP push reader is the odd one
49/// out. Ruled 2026-07-28 by Waffles the Terrible, coordinator seat
50/// (PUSH-HANDSHAKE-DEADLINE; see `docs/design/WIRING-LEDGER.md` and
51/// `docs/design/sdk/briefs/SDK-010.json`).
52///
53/// It is armed for the control exchange ONLY — `Connect`/`ConnectAck`,
54/// `WorkerRegister`/`WorkerRegisterAck`, `Subscribe`/`SubscribeAck` — and
55/// disarmed with `set_read_timeout(None)` before any background reader starts.
56/// It MUST NOT survive into steady state: a deadline that outlives its exchange
57/// is just a slower cadence, and LAW-1 refuses cadences whatever their period.
58///
59/// What it replaces was never chosen. `connect_socket` armed a 100 ms reader
60/// poll cadence before the handshake, and the synchronous setup read was fatal
61/// on the first timeout — composing, by accident, into a 100 ms-per-read fatal
62/// deadline on connect. Nobody chose it.
63#[cfg(feature = "std")]
64pub(crate) const SETUP_TIMEOUT: core::time::Duration = core::time::Duration::from_secs(5);
65
66/// Application-level address for a remote liminal server.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct ServerAddress(String);
69
70impl ServerAddress {
71    /// Creates and validates a remote server address.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`SdkError`] when the supplied address is empty.
76    pub fn new(value: impl Into<String>) -> Result<Self, SdkError> {
77        let value = value.into();
78        if value.trim().is_empty() {
79            return Err(connection_error("remote mode requires a server address"));
80        }
81        Ok(Self(value))
82    }
83
84    /// Returns the server address string.
85    #[must_use]
86    pub fn as_str(&self) -> &str {
87        self.0.as_str()
88    }
89}
90
91/// Configuration for remote SDK handles.
92#[derive(Clone, Debug)]
93pub struct RemoteConfig {
94    /// Remote server address. Remote mode cannot be created without this value.
95    pub server_address: ServerAddress,
96    /// Application-visible channel name.
97    pub channel_name: String,
98    /// Application-visible conversation identifier.
99    pub conversation_id: ConversationId,
100    /// Caller/runtime-supplied connection pool configuration.
101    pub pool_config: ConnectionPoolConfig,
102    transport: Arc<dyn RemoteTransport>,
103    /// The concretely typed WebSocket transport, retained when
104    /// [`connect_websocket`](Self::connect_websocket) installed it so callers
105    /// can drive its typed reconnect path.
106    #[cfg(feature = "std")]
107    websocket: Option<Arc<websocket::WebSocketRemoteTransport>>,
108}
109
110impl RemoteConfig {
111    /// Creates remote configuration with a required server address and pool config.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`SdkError`] if the address or pool configuration is invalid.
116    pub fn new(
117        server_address: impl Into<String>,
118        channel_name: impl Into<String>,
119        conversation_id: impl Into<ConversationId>,
120        pool_config: ConnectionPoolConfig,
121    ) -> Result<Self, SdkError> {
122        Ok(Self {
123            server_address: ServerAddress::new(server_address)?,
124            channel_name: channel_name.into(),
125            conversation_id: conversation_id.into(),
126            pool_config: pool_config.validate()?,
127            transport: Arc::new(ProtocolRemoteTransport),
128            #[cfg(feature = "std")]
129            websocket: None,
130        })
131    }
132
133    /// Opens a real TCP connection to the configured server and installs the
134    /// live wire transport, replacing the in-process protocol transport.
135    ///
136    /// This performs the protocol handshake (`Connect` -> `ConnectAck`) eagerly,
137    /// so a returned configuration is already connected to the server. Subsequent
138    /// publish, subscribe, and conversation calls traverse the socket.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`SdkError::Connection`] when the TCP connection cannot be
143    /// established and [`SdkError::Protocol`] when the handshake is rejected.
144    #[cfg(feature = "std")]
145    pub fn connect_tcp(mut self) -> Result<Self, SdkError> {
146        let transport = self::tcp::TcpRemoteTransport::connect(&self.server_address)?;
147        self.transport = Arc::new(transport);
148        self.websocket = None;
149        Ok(self)
150    }
151
152    /// Opens a real TCP connection whose handshake carries `auth_token`, for a
153    /// server gated by an `[auth]` section, and installs the live wire transport.
154    ///
155    /// Additive to [`connect_tcp`]: an empty token behaves identically to it. The
156    /// server compares the token during the handshake and closes the connection on
157    /// a mismatch, which surfaces here as [`SdkError::Connection`].
158    ///
159    /// # Errors
160    ///
161    /// Returns [`SdkError::Connection`] when the TCP connection cannot be
162    /// established or the token is rejected, and [`SdkError::Protocol`] when the
163    /// handshake frames cannot be encoded or sent.
164    ///
165    /// [`connect_tcp`]: Self::connect_tcp
166    #[cfg(feature = "std")]
167    pub fn connect_tcp_with_auth(mut self, auth_token: &[u8]) -> Result<Self, SdkError> {
168        let transport =
169            self::tcp::TcpRemoteTransport::connect_with_auth(&self.server_address, auth_token)?;
170        self.transport = Arc::new(transport);
171        self.websocket = None;
172        Ok(self)
173    }
174
175    /// Opens a real WebSocket connection to the configured `ws://` server
176    /// address and installs the live wire transport, replacing the in-process
177    /// protocol transport.
178    ///
179    /// This performs the WebSocket upgrade and the protocol handshake
180    /// (`Connect` -> `ConnectAck`) eagerly through the client unit's typed
181    /// permit path, so a returned configuration is already connected.
182    /// Subsequent publish, subscribe, and conversation calls traverse the
183    /// socket; the concretely typed transport stays reachable through
184    /// [`websocket_transport`](Self::websocket_transport) for the typed
185    /// reconnect path.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`SdkError::Connection`] when the address is not a usable
190    /// `ws://` URL, the connection cannot be established, or the handshake is
191    /// rejected, and [`SdkError::Protocol`] when frames cannot be encoded.
192    #[cfg(feature = "std")]
193    pub fn connect_websocket(self) -> Result<Self, SdkError> {
194        self.connect_websocket_with_auth(&[])
195    }
196
197    /// Opens a real WebSocket connection whose handshake carries
198    /// `auth_token`, for a server gated by an `[auth]` section, and installs
199    /// the live wire transport. Additive to [`connect_websocket`]: an empty
200    /// token behaves identically to it.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`SdkError::Connection`] when the connection cannot be
205    /// established or the token is rejected, and [`SdkError::Protocol`] when
206    /// the handshake frames cannot be encoded or sent.
207    ///
208    /// [`connect_websocket`]: Self::connect_websocket
209    #[cfg(feature = "std")]
210    pub fn connect_websocket_with_auth(mut self, auth_token: &[u8]) -> Result<Self, SdkError> {
211        let transport = Arc::new(websocket::WebSocketRemoteTransport::connect_with_auth(
212            &self.server_address,
213            auth_token,
214        )?);
215        self.transport = Arc::clone(&transport) as Arc<dyn RemoteTransport>;
216        self.websocket = Some(transport);
217        Ok(self)
218    }
219
220    /// The concretely typed WebSocket transport installed by
221    /// [`connect_websocket`](Self::connect_websocket), when one is installed.
222    #[cfg(feature = "std")]
223    #[must_use]
224    pub fn websocket_transport(&self) -> Option<Arc<websocket::WebSocketRemoteTransport>> {
225        self.websocket.clone()
226    }
227}
228
229fn connection_error(description: &str) -> SdkError {
230    SdkError::Connection {
231        description: description.to_string(),
232    }
233}