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}