1use std::sync::{Arc, Mutex};
18
19use syncular_client::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
20
21use crate::EventQueue;
22
23pub enum Inbound {
25 Text(String),
26 Binary(Vec<u8>),
27}
28
29#[derive(Default)]
33pub struct InboundBuffer {
34 frames: Mutex<Vec<Inbound>>,
35}
36
37impl InboundBuffer {
38 pub fn push(&self, frame: Inbound) {
39 self.frames.lock().expect("inbound lock").push(frame);
40 }
41 fn take(&self) -> Vec<Inbound> {
42 std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
43 }
44}
45
46pub enum HostTransport {
47 Null {
49 signed_urls: bool,
50 inbound: Arc<InboundBuffer>,
51 },
52 #[cfg(feature = "native-transport")]
53 Native(native::NativeTransport),
54}
55
56impl HostTransport {
57 pub(crate) fn from_config(
60 config: &serde_json::Value,
61 queue: Arc<EventQueue>,
62 ) -> Result<Self, String> {
63 let _ = &queue;
64 Self::new_from_config(config)
65 }
66
67 pub fn new_from_config(config: &serde_json::Value) -> Result<Self, String> {
70 #[cfg(feature = "native-transport")]
71 {
72 if let Some(base_url) = config.get("baseUrl").and_then(|v| v.as_str()) {
73 return Ok(HostTransport::Native(native::NativeTransport::new(
74 base_url, config,
75 )?));
76 }
77 }
78 #[cfg(not(feature = "native-transport"))]
79 {
80 if config.get("baseUrl").is_some() {
81 return Err(
82 "this build has no native transport (rebuild with --features native-transport)"
83 .to_owned(),
84 );
85 }
86 }
87 Ok(HostTransport::Null {
88 signed_urls: false,
89 inbound: Arc::new(InboundBuffer::default()),
90 })
91 }
92
93 pub fn set_signed_urls(&mut self, value: bool) {
94 match self {
95 HostTransport::Null { signed_urls, .. } => *signed_urls = value,
96 #[cfg(feature = "native-transport")]
97 HostTransport::Native(t) => t.signed_urls = value,
98 }
99 }
100
101 pub fn take_inbound(&mut self) -> Vec<Inbound> {
103 match self {
104 HostTransport::Null { inbound, .. } => inbound.take(),
105 #[cfg(feature = "native-transport")]
106 HostTransport::Native(t) => t.inbound.take(),
107 }
108 }
109
110 pub fn shutdown(&mut self) {
112 match self {
113 HostTransport::Null { .. } => {}
114 #[cfg(feature = "native-transport")]
115 HostTransport::Native(t) => t.shutdown(),
116 }
117 }
118}
119
120fn unavailable(op: &str) -> TransportError {
121 TransportError::new(
122 "transport.unavailable",
123 format!("{op} needs the native transport (build with --features native-transport)"),
124 )
125}
126
127#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
130impl Transport for HostTransport {
131 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
132 match self {
133 HostTransport::Null { .. } => Err(unavailable("sync")),
134 #[cfg(feature = "native-transport")]
135 HostTransport::Native(t) => t.sync(request),
136 }
137 }
138
139 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
140 match self {
141 HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
142 #[cfg(feature = "native-transport")]
143 HostTransport::Native(t) => t.realtime_sync(request),
144 }
145 }
146
147 fn download_segment(&mut self, request: &SegmentRequest) -> Result<Vec<u8>, TransportError> {
148 match self {
149 HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
150 #[cfg(feature = "native-transport")]
151 HostTransport::Native(t) => t.download_segment(request),
152 }
153 }
154
155 fn supports_url_fetch(&self) -> bool {
156 match self {
157 HostTransport::Null { signed_urls, .. } => *signed_urls,
158 #[cfg(feature = "native-transport")]
159 HostTransport::Native(t) => t.signed_urls,
160 }
161 }
162
163 fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
164 match self {
165 HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
166 #[cfg(feature = "native-transport")]
167 HostTransport::Native(t) => t.fetch_url(url),
168 }
169 }
170
171 fn blob_upload(
172 &mut self,
173 blob_id: &str,
174 bytes: &[u8],
175 media_type: Option<&str>,
176 ) -> Result<(), TransportError> {
177 match self {
178 HostTransport::Null { .. } => Err(unavailable("blobUpload")),
179 #[cfg(feature = "native-transport")]
180 HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
181 }
182 }
183
184 fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
185 match self {
186 HostTransport::Null { .. } => Err(unavailable("blobDownload")),
187 #[cfg(feature = "native-transport")]
188 HostTransport::Native(t) => t.blob_download(blob_id),
189 }
190 }
191
192 fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
193 match self {
194 HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
195 #[cfg(feature = "native-transport")]
196 HostTransport::Native(t) => t.fetch_blob_url(url),
197 }
198 }
199
200 fn blob_upload_grant(
201 &mut self,
202 blob_id: &str,
203 byte_length: u64,
204 media_type: Option<&str>,
205 ) -> Result<BlobUploadGrant, TransportError> {
206 match self {
207 HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
210 #[cfg(feature = "native-transport")]
211 HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
212 }
213 }
214
215 fn blob_put_url(
216 &mut self,
217 url: &str,
218 bytes: &[u8],
219 media_type: Option<&str>,
220 ) -> Result<(), TransportError> {
221 match self {
222 HostTransport::Null { .. } => Err(unavailable("blobPutUrl")),
223 #[cfg(feature = "native-transport")]
224 HostTransport::Native(t) => t.blob_put_url(url, bytes, media_type),
225 }
226 }
227
228 fn realtime_connect(&mut self) -> Result<(), TransportError> {
229 match self {
230 HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
231 #[cfg(feature = "native-transport")]
232 HostTransport::Native(t) => t.realtime_connect(),
233 }
234 }
235
236 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
237 match self {
238 HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
239 #[cfg(feature = "native-transport")]
240 HostTransport::Native(t) => t.realtime_send(text),
241 }
242 }
243
244 fn realtime_close(&mut self) -> Result<(), TransportError> {
245 match self {
246 HostTransport::Null { .. } => Ok(()),
247 #[cfg(feature = "native-transport")]
248 HostTransport::Native(t) => t.realtime_close(),
249 }
250 }
251}
252
253#[cfg(feature = "native-transport")]
254mod native {
255 use std::io::Read;
267 use std::net::TcpStream;
268 use std::sync::atomic::{AtomicBool, Ordering};
269 use std::sync::{Arc, Condvar, Mutex};
270 use std::thread::JoinHandle;
271 use std::time::Duration;
272
273 use tungstenite::stream::MaybeTlsStream;
274 use tungstenite::{Message, WebSocket};
275
276 use super::{Inbound, InboundBuffer};
277 use syncular_client::{
278 BlobDownload, BlobUploadGrant, RealtimeRound, RoundInbound, SegmentRequest, Transport,
279 TransportError,
280 };
281
282 type Ws = WebSocket<MaybeTlsStream<TcpStream>>;
283
284 const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
288 const READ_TIMEOUT: Duration = Duration::from_millis(5);
295 const READ_YIELD: Duration = Duration::from_micros(500);
301
302 #[derive(Default)]
309 pub(super) struct RoundChannel {
310 state: Mutex<RoundState>,
311 ready: Condvar,
312 }
313
314 #[derive(Default)]
315 struct RoundState {
316 round: RealtimeRound,
317 outcome: Option<Result<Vec<u8>, TransportError>>,
319 }
320
321 impl RoundChannel {
322 fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
326 let mut state = self.state.lock().expect("round lock");
327 state.outcome = None;
328 state.round.begin(request)
329 }
330
331 fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
335 let mut state = self.state.lock().expect("round lock");
336 match state.round.route_binary(frame) {
337 Ok(RoundInbound::Delta(body)) => Some(body),
338 Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
339 Ok(RoundInbound::RoundComplete(bytes)) => {
340 state.outcome = Some(Ok(bytes));
341 self.ready.notify_all();
342 None
343 }
344 Err(error) => {
345 state.outcome = Some(Err(error));
346 self.ready.notify_all();
347 None
348 }
349 }
350 }
351
352 fn fail_in_flight(&self, error: TransportError) {
354 let mut state = self.state.lock().expect("round lock");
355 if state.round.in_flight() && state.outcome.is_none() {
356 state.round.abort();
357 state.outcome = Some(Err(error));
358 self.ready.notify_all();
359 }
360 }
361
362 fn wait(&self) -> Result<Vec<u8>, TransportError> {
364 let mut state = self.state.lock().expect("round lock");
365 let deadline = std::time::Instant::now() + ROUND_TIMEOUT;
366 while state.outcome.is_none() {
367 let now = std::time::Instant::now();
368 if now >= deadline {
369 state.round.abort();
370 return Err(TransportError::new(
371 "sync.transport_failed",
372 "realtime sync round timed out (§8.7)",
373 ));
374 }
375 let (guard, _timeout) = self
376 .ready
377 .wait_timeout(state, deadline - now)
378 .expect("round wait");
379 state = guard;
380 }
381 state.outcome.take().expect("outcome present")
382 }
383 }
384
385 fn http_err(op: &str, e: impl std::fmt::Display) -> TransportError {
386 TransportError::new("transport.failed", format!("{op}: {e}"))
387 }
388
389 fn is_would_block(e: &tungstenite::Error) -> bool {
392 matches!(
393 e,
394 tungstenite::Error::Io(io) if matches!(
395 io.kind(),
396 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
397 )
398 )
399 }
400
401 fn set_read_timeout(ws: &mut Ws, timeout: Option<Duration>) {
404 match ws.get_mut() {
405 MaybeTlsStream::Plain(s) => {
406 let _ = s.set_read_timeout(timeout);
407 }
408 MaybeTlsStream::Rustls(s) => {
409 let _ = s.get_ref().set_read_timeout(timeout);
410 }
411 _ => {}
412 }
413 }
414
415 pub struct NativeTransport {
416 base_url: String,
417 ws_url: String,
418 headers: Vec<(String, String)>,
420 agent: ureq::Agent,
421 pub signed_urls: bool,
422 pub inbound: Arc<InboundBuffer>,
423 socket: Option<Arc<Mutex<Ws>>>,
425 reader: Option<JoinHandle<()>>,
426 reader_stop: Arc<AtomicBool>,
427 round: Arc<RoundChannel>,
429 }
430
431 fn derive_ws_url(base_url: &str) -> String {
432 let ws = if let Some(rest) = base_url.strip_prefix("https://") {
435 format!("wss://{rest}")
436 } else if let Some(rest) = base_url.strip_prefix("http://") {
437 format!("ws://{rest}")
438 } else {
439 base_url.to_owned()
440 };
441 let trimmed = ws.trim_end_matches('/');
442 format!("{trimmed}/realtime")
443 }
444
445 impl NativeTransport {
446 pub fn new(base_url: &str, config: &serde_json::Value) -> Result<Self, String> {
447 let mut headers = Vec::new();
448 if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
449 for (k, v) in map {
450 if let Some(s) = v.as_str() {
451 headers.push((k.clone(), s.to_owned()));
452 }
453 }
454 }
455 let ws_url = config
456 .get("wsUrl")
457 .and_then(|v| v.as_str())
458 .map(str::to_owned)
459 .unwrap_or_else(|| derive_ws_url(base_url));
460 Ok(NativeTransport {
461 base_url: base_url.trim_end_matches('/').to_owned(),
462 ws_url,
463 headers,
464 agent: ureq::AgentBuilder::new().build(),
465 signed_urls: false,
466 inbound: Arc::new(InboundBuffer::default()),
467 socket: None,
468 reader: None,
469 reader_stop: Arc::new(AtomicBool::new(false)),
470 round: Arc::new(RoundChannel::default()),
471 })
472 }
473
474 fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
475 let url = format!("{}{}", self.base_url, path);
476 let mut req = self
479 .agent
480 .post(&url)
481 .set("content-type", "application/vnd.syncular.sync.v2");
482 for (k, v) in &self.headers {
483 req = req.set(k, v);
484 }
485 let resp = req.send_bytes(body).map_err(|e| http_err("POST", e))?;
486 read_body(resp)
487 }
488
489 fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
490 let mut req = self.agent.get(url);
491 if with_headers {
492 for (k, v) in &self.headers {
493 req = req.set(k, v);
494 }
495 }
496 let resp = req.call().map_err(|e| http_err("GET", e))?;
497 read_body(resp)
498 }
499
500 pub fn shutdown(&mut self) {
501 self.reader_stop.store(true, Ordering::SeqCst);
502 self.round.fail_in_flight(TransportError::new(
505 "sync.transport_failed",
506 "realtime disconnected mid-round (§8.7)",
507 ));
508 if let Some(socket) = &self.socket {
509 if let Ok(mut ws) = socket.lock() {
510 let _ = ws.close(None);
511 let _ = ws.flush();
512 }
513 }
514 if let Some(handle) = self.reader.take() {
515 let _ = handle.join();
516 }
517 self.socket = None;
518 }
519 }
520
521 fn read_body(resp: ureq::Response) -> Result<Vec<u8>, TransportError> {
522 let mut buf = Vec::new();
523 resp.into_reader()
524 .read_to_end(&mut buf)
525 .map_err(|e| http_err("read", e))?;
526 Ok(buf)
527 }
528
529 impl Transport for NativeTransport {
530 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
531 self.post_sync("/sync", request)
532 }
533
534 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
535 let Some(socket) = self.socket.clone() else {
545 return self.post_sync("/sync", request);
546 };
547 let framed = self.round.begin(request)?;
548 let send = {
551 let mut ws = socket
552 .lock()
553 .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
554 ws.send(Message::Binary(framed))
555 .map_err(|e| http_err("ws round send", &e))
556 .and_then(|()| ws.flush().map_err(|e| http_err("ws round flush", &e)))
557 };
558 if let Err(e) = send {
559 self.round.fail_in_flight(e);
562 }
563 self.round.wait()
564 }
565
566 fn download_segment(
567 &mut self,
568 request: &SegmentRequest,
569 ) -> Result<Vec<u8>, TransportError> {
570 let url = format!("{}/segments/{}", self.base_url, request.segment_id);
577 let mut req = self
578 .agent
579 .get(&url)
580 .set("x-syncular-scopes", &request.requested_scopes_json);
581 for (k, v) in &self.headers {
582 req = req.set(k, v);
583 }
584 let resp = req.call().map_err(|e| http_err("GET segment", e))?;
585 read_body(resp)
586 }
587
588 fn supports_url_fetch(&self) -> bool {
589 self.signed_urls
590 }
591
592 fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
593 self.get_bytes(url, false)
595 }
596
597 fn blob_upload(
598 &mut self,
599 blob_id: &str,
600 bytes: &[u8],
601 media_type: Option<&str>,
602 ) -> Result<(), TransportError> {
603 let url = format!("{}/blobs/{}", self.base_url, blob_id);
606 let mut req = self.agent.put(&url).set(
607 "content-type",
608 media_type.unwrap_or("application/octet-stream"),
609 );
610 for (k, v) in &self.headers {
611 req = req.set(k, v);
612 }
613 req.send_bytes(bytes).map_err(|e| http_err("PUT blob", e))?;
614 Ok(())
615 }
616
617 fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
618 let url = format!("{}/blobs/{}", self.base_url, blob_id);
621 let mut req = self.agent.get(&url);
622 for (k, v) in &self.headers {
623 req = req.set(k, v);
624 }
625 let resp = req.call().map_err(|e| {
626 let e = http_err("GET blob", e);
627 if e.code == "transport.failed" {
629 TransportError::new("blob.not_found", e.message)
630 } else {
631 e
632 }
633 })?;
634 let is_json = resp
637 .header("content-type")
638 .is_some_and(|ct| ct.contains("application/json"));
639 let body = read_body(resp)?;
640 if is_json {
641 if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
642 if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
643 return Ok(BlobDownload::Url {
644 url: u.to_owned(),
645 url_expires_at_ms: parsed
646 .get("urlExpiresAtMs")
647 .and_then(|v| v.as_i64()),
648 });
649 }
650 }
651 }
652 Ok(BlobDownload::Bytes(body))
653 }
654
655 fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
656 self.get_bytes(url, false)
658 }
659
660 fn blob_upload_grant(
661 &mut self,
662 blob_id: &str,
663 byte_length: u64,
664 media_type: Option<&str>,
665 ) -> Result<BlobUploadGrant, TransportError> {
666 let url = format!("{}/blobs/{}/upload-grant", self.base_url, blob_id);
667 let mut req = self
668 .agent
669 .post(&url)
670 .set("content-type", "application/json");
671 for (k, v) in &self.headers {
672 req = req.set(k, v);
673 }
674 let body = serde_json::json!({
675 "byteLength": byte_length,
676 "mediaType": media_type,
677 });
678 let resp = req
679 .send_string(&body.to_string())
680 .map_err(|e| http_err("POST upload-grant", e))?;
681 let grant_body = read_body(resp)?;
682 let parsed: serde_json::Value = serde_json::from_slice(&grant_body)
683 .map_err(|e| TransportError::new("transport.failed", format!("read grant: {e}")))?;
684 if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
685 return Ok(BlobUploadGrant::Url {
686 url: u.to_owned(),
687 url_expires_at_ms: parsed.get("urlExpiresAtMs").and_then(|v| v.as_i64()),
688 });
689 }
690 if parsed.get("present").and_then(|v| v.as_bool()) == Some(true) {
691 return Ok(BlobUploadGrant::Present);
692 }
693 Ok(BlobUploadGrant::None)
694 }
695
696 fn blob_put_url(
697 &mut self,
698 url: &str,
699 bytes: &[u8],
700 media_type: Option<&str>,
701 ) -> Result<(), TransportError> {
702 let req = self.agent.put(url).set(
704 "content-type",
705 media_type.unwrap_or("application/octet-stream"),
706 );
707 req.send_bytes(bytes)
708 .map_err(|e| http_err("PUT blob url", e))?;
709 Ok(())
710 }
711
712 fn realtime_connect(&mut self) -> Result<(), TransportError> {
713 if self.socket.is_some() {
714 return Ok(());
715 }
716 use tungstenite::client::IntoClientRequest;
722 let mut request = self
723 .ws_url
724 .as_str()
725 .into_client_request()
726 .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
727 {
728 let out = request.headers_mut();
729 for (k, v) in &self.headers {
730 if let (Ok(name), Ok(value)) = (
731 tungstenite::http::HeaderName::try_from(k.as_str()),
732 tungstenite::http::HeaderValue::try_from(v.as_str()),
733 ) {
734 out.insert(name, value);
735 }
736 }
737 }
738 let (mut ws, _resp) = tungstenite::connect(request)
739 .map_err(|e| TransportError::new("transport.failed", format!("ws connect: {e}")))?;
740 set_read_timeout(&mut ws, Some(READ_TIMEOUT));
744 let socket = Arc::new(Mutex::new(ws));
745 self.socket = Some(Arc::clone(&socket));
746 self.reader_stop.store(false, Ordering::SeqCst);
751 let inbound = Arc::clone(&self.inbound);
752 let stop = Arc::clone(&self.reader_stop);
753 let reader_socket = Arc::clone(&socket);
754 let round = Arc::clone(&self.round);
755 self.reader = Some(std::thread::spawn(move || loop {
756 if stop.load(Ordering::SeqCst) {
757 break;
758 }
759 let msg = {
760 let mut ws = match reader_socket.lock() {
761 Ok(ws) => ws,
762 Err(_) => break,
763 };
764 ws.read()
765 };
766 match msg {
767 Ok(Message::Text(text)) => inbound.push(Inbound::Text(text)),
768 Ok(Message::Binary(bytes)) => {
769 if let Some(delta) = round.route_binary(&bytes) {
773 inbound.push(Inbound::Binary(delta));
774 }
775 }
776 Err(e) if is_would_block(&e) => {
781 std::thread::sleep(READ_YIELD);
782 continue;
783 }
784 Ok(Message::Close(_)) | Err(_) => {
785 round.fail_in_flight(TransportError::new(
788 "sync.transport_failed",
789 "realtime disconnected mid-round (§8.7)",
790 ));
791 break;
792 }
793 Ok(_) => {}
794 }
795 }));
796 Ok(())
797 }
798
799 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
800 let Some(socket) = &self.socket else {
801 return Err(TransportError::new(
802 "transport.failed",
803 "realtime not connected",
804 ));
805 };
806 let mut ws = socket
807 .lock()
808 .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
809 ws.send(Message::Text(text.to_owned()))
810 .map_err(|e| http_err("ws send", e))?;
811 ws.flush().map_err(|e| http_err("ws flush", e))?;
812 Ok(())
813 }
814
815 fn realtime_close(&mut self) -> Result<(), TransportError> {
816 self.shutdown();
817 Ok(())
818 }
819 }
820}