Skip to main content

syncular_client/
native_transport.rs

1//! Shared native host transport for every Rust-backed Syncular binding.
2//!
3//! Two shapes behind one `HostTransport`:
4//!
5//! - `Null` (the dependency-lean default build): every network op fails loudly
6//!   with `transport.unavailable`. Client-local commands (create, subscribe,
7//!   mutate, readRows, conflicts, …) still run — enough for the C smoke test
8//!   and pure-logic tests, with zero HTTP/WS dependency compiled in.
9//! - `Native` (the `native-transport` feature): a real HTTP + WS client the
10//!   core drives itself, because a native app has no host loop to invert
11//!   transport into (unlike the conformance shim). `ureq` for blocking HTTP,
12//!   `tungstenite` for the WS socket; a reader thread buffers inbound frames.
13//!
14//! Inbound realtime frames land in a shared queue the host drains after each
15//! command. An optional wake callback lets mailbox-driven hosts react without
16//! polling. The socket implementation is deliberately singular: fairness and
17//! wire fixes therefore reach FFI, Tauri, and future Rust hosts together.
18
19use std::sync::{Arc, Mutex};
20
21use crate::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
22
23/// One inbound realtime frame buffered for the client's `on_realtime_*`.
24pub enum Inbound {
25    Text(String),
26    Binary(Vec<u8>),
27}
28
29/// The shared inbound buffer the WS reader thread fills and the command path
30/// drains. The optional callback carries no data; it only wakes an owning
31/// mailbox so the host can drain the buffer promptly.
32pub struct InboundBuffer {
33    frames: Mutex<Vec<Inbound>>,
34    notify: Option<Arc<dyn Fn() + Send + Sync>>,
35}
36
37impl Default for InboundBuffer {
38    fn default() -> Self {
39        Self {
40            frames: Mutex::new(Vec::new()),
41            notify: None,
42        }
43    }
44}
45
46impl InboundBuffer {
47    pub fn with_notify(notify: Arc<dyn Fn() + Send + Sync>) -> Self {
48        Self {
49            frames: Mutex::new(Vec::new()),
50            notify: Some(notify),
51        }
52    }
53
54    pub fn push(&self, frame: Inbound) {
55        self.frames.lock().expect("inbound lock").push(frame);
56        if let Some(notify) = &self.notify {
57            notify();
58        }
59    }
60    fn take(&self) -> Vec<Inbound> {
61        std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
62    }
63}
64
65pub enum HostTransport {
66    /// No network: client-local commands only (dependency-lean default).
67    Null {
68        signed_urls: bool,
69        inbound: Arc<InboundBuffer>,
70    },
71    #[cfg(feature = "native-transport")]
72    Native(native::NativeTransport),
73}
74
75impl HostTransport {
76    /// Build the transport from the `new` config. `{}` (or no `baseUrl`) →
77    /// `Null`; a `baseUrl` under the `native-transport` feature → `Native`.
78    pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
79        Self::new_from_config(config)
80    }
81
82    /// [`Self::from_config`] for hosts outside this crate (the bench driver)
83    /// that own their inbound-frame drain and need no FFI event queue.
84    pub fn new_from_config(config: &serde_json::Value) -> Result<Self, String> {
85        Self::from_config_with_notify(config, None)
86    }
87
88    /// Build a transport whose realtime reader wakes a mailbox/event loop.
89    /// The callback is invoked after a frame is buffered and never carries
90    /// protocol data itself.
91    pub fn from_config_with_notify(
92        config: &serde_json::Value,
93        notify: Option<Arc<dyn Fn() + Send + Sync>>,
94    ) -> Result<Self, String> {
95        #[cfg(feature = "native-transport")]
96        {
97            if let Some(base_url) = config.get("baseUrl").and_then(|v| v.as_str()) {
98                return Ok(HostTransport::Native(native::NativeTransport::new(
99                    base_url, config, notify,
100                )?));
101            }
102        }
103        #[cfg(not(feature = "native-transport"))]
104        {
105            if config.get("baseUrl").is_some() {
106                return Err(
107                    "this build has no native transport (rebuild with --features native-transport)"
108                        .to_owned(),
109                );
110            }
111        }
112        Ok(HostTransport::Null {
113            signed_urls: false,
114            inbound: Arc::new(match notify {
115                Some(notify) => InboundBuffer::with_notify(notify),
116                None => InboundBuffer::default(),
117            }),
118        })
119    }
120
121    pub fn set_signed_urls(&mut self, value: bool) {
122        match self {
123            HostTransport::Null { signed_urls, .. } => *signed_urls = value,
124            #[cfg(feature = "native-transport")]
125            HostTransport::Native(t) => t.signed_urls = value,
126        }
127    }
128
129    /// Replace auth/application headers for subsequent HTTP requests and the
130    /// next realtime connection. A live socket retains its handshake headers.
131    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
132        match self {
133            HostTransport::Null { .. } => drop(headers),
134            #[cfg(feature = "native-transport")]
135            HostTransport::Native(t) => t.set_headers(headers),
136        }
137    }
138
139    /// Drain the inbound realtime frames buffered since the last call.
140    pub fn take_inbound(&mut self) -> Vec<Inbound> {
141        match self {
142            HostTransport::Null { inbound, .. } => inbound.take(),
143            #[cfg(feature = "native-transport")]
144            HostTransport::Native(t) => t.inbound.take(),
145        }
146    }
147
148    /// Release the socket/reader thread. Idempotent.
149    pub fn shutdown(&mut self) {
150        match self {
151            HostTransport::Null { .. } => {}
152            #[cfg(feature = "native-transport")]
153            HostTransport::Native(t) => t.shutdown(),
154        }
155    }
156}
157
158fn unavailable(op: &str) -> TransportError {
159    TransportError::new(
160        "transport.unavailable",
161        format!("{op} needs the native transport (build with --features native-transport)"),
162    )
163}
164
165// Without the native transport the `Null` arm ignores every request payload;
166// the params are only consumed by the feature-gated `Native` arm.
167#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
168impl Transport for HostTransport {
169    fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
170        match self {
171            HostTransport::Null { .. } => Err(unavailable("sync")),
172            #[cfg(feature = "native-transport")]
173            HostTransport::Native(t) => t.sync(request),
174        }
175    }
176
177    fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
178        match self {
179            HostTransport::Null { .. } => Err(unavailable("remoteOperation")),
180            #[cfg(feature = "native-transport")]
181            HostTransport::Native(t) => t.remote_operation(request),
182        }
183    }
184
185    fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
186        match self {
187            HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
188            #[cfg(feature = "native-transport")]
189            HostTransport::Native(t) => t.realtime_sync(request),
190        }
191    }
192
193    fn download_segment(&mut self, request: &SegmentRequest) -> Result<Vec<u8>, TransportError> {
194        match self {
195            HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
196            #[cfg(feature = "native-transport")]
197            HostTransport::Native(t) => t.download_segment(request),
198        }
199    }
200
201    fn supports_url_fetch(&self) -> bool {
202        match self {
203            HostTransport::Null { signed_urls, .. } => *signed_urls,
204            #[cfg(feature = "native-transport")]
205            HostTransport::Native(t) => t.signed_urls,
206        }
207    }
208
209    fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
210        match self {
211            HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
212            #[cfg(feature = "native-transport")]
213            HostTransport::Native(t) => t.fetch_url(url),
214        }
215    }
216
217    fn blob_upload(
218        &mut self,
219        blob_id: &str,
220        bytes: &[u8],
221        media_type: Option<&str>,
222    ) -> Result<(), TransportError> {
223        match self {
224            HostTransport::Null { .. } => Err(unavailable("blobUpload")),
225            #[cfg(feature = "native-transport")]
226            HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
227        }
228    }
229
230    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
231        match self {
232            HostTransport::Null { .. } => Err(unavailable("blobDownload")),
233            #[cfg(feature = "native-transport")]
234            HostTransport::Native(t) => t.blob_download(blob_id),
235        }
236    }
237
238    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
239        match self {
240            HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
241            #[cfg(feature = "native-transport")]
242            HostTransport::Native(t) => t.fetch_blob_url(url),
243        }
244    }
245
246    fn blob_upload_grant(
247        &mut self,
248        blob_id: &str,
249        byte_length: u64,
250        media_type: Option<&str>,
251    ) -> Result<BlobUploadGrant, TransportError> {
252        match self {
253            // No grant available ⇒ the client streams through the direct
254            // upload endpoint (§5.9.3 capability, not fallback).
255            HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
256            #[cfg(feature = "native-transport")]
257            HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
258        }
259    }
260
261    fn blob_put_url(
262        &mut self,
263        url: &str,
264        bytes: &[u8],
265        media_type: Option<&str>,
266    ) -> Result<(), TransportError> {
267        match self {
268            HostTransport::Null { .. } => Err(unavailable("blobPutUrl")),
269            #[cfg(feature = "native-transport")]
270            HostTransport::Native(t) => t.blob_put_url(url, bytes, media_type),
271        }
272    }
273
274    fn realtime_connect(&mut self) -> Result<(), TransportError> {
275        match self {
276            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
277            #[cfg(feature = "native-transport")]
278            HostTransport::Native(t) => {
279                t.set_realtime_client_id(None);
280                t.realtime_connect()
281            }
282        }
283    }
284
285    fn realtime_connect_for_client(&mut self, client_id: &str) -> Result<(), TransportError> {
286        match self {
287            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
288            #[cfg(feature = "native-transport")]
289            HostTransport::Native(t) => {
290                t.set_realtime_client_id(Some(client_id));
291                t.realtime_connect()
292            }
293        }
294    }
295
296    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
297        match self {
298            HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
299            #[cfg(feature = "native-transport")]
300            HostTransport::Native(t) => t.realtime_send(text),
301        }
302    }
303
304    fn realtime_close(&mut self) -> Result<(), TransportError> {
305        match self {
306            HostTransport::Null { .. } => Ok(()),
307            #[cfg(feature = "native-transport")]
308            HostTransport::Native(t) => t.realtime_close(),
309        }
310    }
311}
312
313#[cfg(feature = "native-transport")]
314mod native {
315    //! The real native HTTP + WS transport. HTTP via `ureq` (blocking, no
316    //! async runtime — matches the client's synchronous API); WS via
317    //! `tungstenite`, with a reader thread pushing inbound frames into the
318    //! shared `InboundBuffer`.
319    //!
320    //! Wire contract mirrors the reference HTTP+WS bindings (§1.1, §8.7):
321    //! `POST {baseUrl}/sync` (application/vnd.syncular.sync.v2, `X-Syncular-Scopes`
322    //! not needed here — the native app authenticates via configured headers),
323    //! `GET {baseUrl}/segments/{id}`, `PUT/GET {baseUrl}/blobs/{id}`, and the
324    //! realtime socket at `{wsUrl}` (ws(s):// derived from baseUrl).
325
326    use std::net::TcpStream;
327    use std::sync::atomic::{AtomicBool, Ordering};
328    use std::sync::{mpsc, Arc, Condvar, Mutex};
329    use std::thread::JoinHandle;
330    use std::time::Duration;
331
332    use polling::{Event, Events, Poller};
333    use tungstenite::stream::MaybeTlsStream;
334    use tungstenite::Message;
335
336    use super::{Inbound, InboundBuffer};
337    use crate::{
338        BlobDownload, BlobUploadGrant, RealtimeRound, RoundInbound, SegmentRequest, Transport,
339        TransportError,
340    };
341
342    // Unregister before the cloned socket handle is closed, including on unwind.
343    struct SocketRegistration {
344        poller: Arc<Poller>,
345        stream: TcpStream,
346    }
347
348    impl Drop for SocketRegistration {
349        fn drop(&mut self) {
350            let _ = self.poller.delete(&self.stream);
351        }
352    }
353
354    struct SocketSender {
355        queue: mpsc::SyncSender<Outgoing>,
356        poller: Arc<Poller>,
357    }
358
359    struct Outgoing {
360        message: Message,
361        completed: mpsc::Sender<Result<(), TransportError>>,
362    }
363
364    /// How long a single response round waits before giving up (§8.7 rounds
365    /// are bounded — bulk rides segments over HTTP). Generous; a stuck socket
366    /// surfaces as a transport failure rather than hanging the caller forever.
367    const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
368    /// The §8.7 round rendezvous shared between the reader thread (which
369    /// demuxes inbound `0x01` chunks into the round via [`RealtimeRound`]) and
370    /// `realtime_sync` (which begins the round, sends the request, and blocks
371    /// here for the reassembled response). The transport-agnostic framing
372    /// logic lives in [`RealtimeRound`] (the lean client crate, shared with
373    /// the Tauri plugin); this struct is just the thread rendezvous.
374    #[derive(Default)]
375    pub(super) struct RoundChannel {
376        state: Mutex<RoundState>,
377        ready: Condvar,
378    }
379
380    #[derive(Default)]
381    struct RoundState {
382        round: RealtimeRound,
383        /// The completed round outcome, taken by `realtime_sync` once set.
384        outcome: Option<Result<Vec<u8>, TransportError>>,
385    }
386
387    impl RoundChannel {
388        /// Begin a round: frame the request (`0x01` tag + envelope) for the
389        /// socket and mark it in flight. Errors if one is already in flight
390        /// (§8.7 one-in-flight, enforced client-side).
391        fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
392            let mut state = self.state.lock().expect("round lock");
393            state.outcome = None;
394            state.round.begin(request)
395        }
396
397        /// Route one inbound binary frame from the reader thread. Returns the
398        /// delta payload to enqueue on the inbound buffer, if any; a completed
399        /// or failed round is stored and the waiting `realtime_sync` woken.
400        fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
401            let mut state = self.state.lock().expect("round lock");
402            match state.round.route_binary(frame) {
403                Ok(RoundInbound::Delta(body)) => Some(body),
404                Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
405                Ok(RoundInbound::RoundComplete(bytes)) => {
406                    state.outcome = Some(Ok(bytes));
407                    self.ready.notify_all();
408                    None
409                }
410                Err(error) => {
411                    state.outcome = Some(Err(error));
412                    self.ready.notify_all();
413                    None
414                }
415            }
416        }
417
418        /// Fail any in-flight round (socket dropped) and wake the waiter.
419        fn fail_in_flight(&self, error: TransportError) {
420            let mut state = self.state.lock().expect("round lock");
421            if state.round.in_flight() && state.outcome.is_none() {
422                state.round.abort();
423                state.outcome = Some(Err(error));
424                self.ready.notify_all();
425            }
426        }
427
428        /// Block until the round completes, fails, or `ROUND_TIMEOUT` elapses.
429        fn wait(&self) -> Result<Vec<u8>, TransportError> {
430            let mut state = self.state.lock().expect("round lock");
431            let deadline = std::time::Instant::now() + ROUND_TIMEOUT;
432            while state.outcome.is_none() {
433                let now = std::time::Instant::now();
434                if now >= deadline {
435                    state.round.abort();
436                    return Err(TransportError::new(
437                        "sync.transport_failed",
438                        "realtime sync round timed out (§8.7)",
439                    ));
440                }
441                let (guard, _timeout) = self
442                    .ready
443                    .wait_timeout(state, deadline - now)
444                    .expect("round wait");
445                state = guard;
446            }
447            state.outcome.take().expect("outcome present")
448        }
449    }
450
451    fn http_err(op: &str, e: impl std::fmt::Display) -> TransportError {
452        TransportError::new("transport.failed", format!("{op}: {e}"))
453    }
454
455    /// Nonblocking I/O needs a readiness notification before it can continue.
456    fn is_would_block(e: &tungstenite::Error) -> bool {
457        matches!(
458            e,
459            tungstenite::Error::Io(io) if matches!(
460                io.kind(),
461                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
462            )
463        )
464    }
465
466    pub struct NativeTransport {
467        base_url: String,
468        ws_url: String,
469        /// Extra request headers (auth, actor/project ids) as (name, value).
470        headers: Vec<(String, String)>,
471        agent: ureq::Agent,
472        pub signed_urls: bool,
473        pub inbound: Arc<InboundBuffer>,
474        /// One I/O thread owns the socket; callers wait for their write to flush.
475        outgoing: Option<Box<SocketSender>>,
476        reader: Option<JoinHandle<()>>,
477        reader_stop: Arc<AtomicBool>,
478        /// §8.7 round rendezvous, shared with the reader thread.
479        round: Arc<RoundChannel>,
480        realtime_client_id: Option<String>,
481    }
482
483    fn derive_ws_url(base_url: &str) -> String {
484        // {scheme}://host/path → ws(s)://host/path/realtime — the reference
485        // realtime endpoint sits alongside /sync under the mount (§8.7).
486        let ws = if let Some(rest) = base_url.strip_prefix("https://") {
487            format!("wss://{rest}")
488        } else if let Some(rest) = base_url.strip_prefix("http://") {
489            format!("ws://{rest}")
490        } else {
491            base_url.to_owned()
492        };
493        let trimmed = ws.trim_end_matches('/');
494        format!("{trimmed}/realtime")
495    }
496
497    impl NativeTransport {
498        pub fn new(
499            base_url: &str,
500            config: &serde_json::Value,
501            notify: Option<Arc<dyn Fn() + Send + Sync>>,
502        ) -> Result<Self, String> {
503            let mut headers = Vec::new();
504            if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
505                for (k, v) in map {
506                    if let Some(s) = v.as_str() {
507                        headers.push((k.clone(), s.to_owned()));
508                    }
509                }
510            }
511            let ws_url = config
512                .get("wsUrl")
513                .and_then(|v| v.as_str())
514                .map(str::to_owned)
515                .unwrap_or_else(|| derive_ws_url(base_url));
516            Ok(NativeTransport {
517                base_url: base_url.trim_end_matches('/').to_owned(),
518                ws_url,
519                headers,
520                agent: ureq::Agent::new_with_defaults(),
521                signed_urls: false,
522                inbound: Arc::new(match notify {
523                    Some(notify) => InboundBuffer::with_notify(notify),
524                    None => InboundBuffer::default(),
525                }),
526                outgoing: None,
527                reader: None,
528                reader_stop: Arc::new(AtomicBool::new(false)),
529                round: Arc::new(RoundChannel::default()),
530                realtime_client_id: None,
531            })
532        }
533
534        fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
535            let url = format!("{}{}", self.base_url, path);
536            // SSP2 requests carry their own media type (SPEC 1.1); a stock
537            // server answers 415 to anything else.
538            let mut req = self
539                .agent
540                .post(&url)
541                .header("content-type", "application/vnd.syncular.sync.v2");
542            for (k, v) in &self.headers {
543                req = req.header(k.as_str(), v.as_str());
544            }
545            let resp = req.send(body).map_err(|e| http_err("POST", e))?;
546            read_body(resp)
547        }
548
549        fn post_operation(&self, body: &[u8]) -> Result<Vec<u8>, TransportError> {
550            let url = format!("{}/operations", self.base_url);
551            let mut req = self.agent.post(&url).header(
552                "content-type",
553                "application/vnd.syncular.operations.v1+json",
554            );
555            for (key, value) in &self.headers {
556                req = req.header(key.as_str(), value.as_str());
557            }
558            let response = req
559                .send(body)
560                .map_err(|error| http_err("POST operation", error))?;
561            read_body(response)
562        }
563
564        fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
565            let mut req = self.agent.get(url);
566            if with_headers {
567                for (k, v) in &self.headers {
568                    req = req.header(k.as_str(), v.as_str());
569                }
570            }
571            let resp = req.call().map_err(|e| http_err("GET", e))?;
572            read_body(resp)
573        }
574
575        pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
576            self.headers = headers;
577        }
578
579        pub fn set_realtime_client_id(&mut self, client_id: Option<&str>) {
580            self.realtime_client_id = client_id.map(str::to_owned);
581        }
582
583        pub fn shutdown(&mut self) {
584            self.reader_stop.store(true, Ordering::SeqCst);
585            // Wake any `realtime_sync` blocked on a round: the socket is going
586            // away, so the round can never complete (§8.7 mid-round drop).
587            self.round.fail_in_flight(TransportError::new(
588                "sync.transport_failed",
589                "realtime disconnected mid-round (§8.7)",
590            ));
591            if let Some(outgoing) = &self.outgoing {
592                let _ = outgoing.poller.notify();
593            }
594            if let Some(handle) = self.reader.take() {
595                let _ = handle.join();
596            }
597            self.outgoing = None;
598        }
599
600        fn send_message(&mut self, message: Message) -> Result<(), TransportError> {
601            let result = (|| {
602                let Some(outgoing) = &self.outgoing else {
603                    return Err(TransportError::new(
604                        "transport.failed",
605                        "realtime not connected",
606                    ));
607                };
608                let (completed, result) = mpsc::channel();
609                outgoing
610                    .queue
611                    .try_send(Outgoing { message, completed })
612                    .map_err(|_| {
613                        TransportError::new("transport.failed", "realtime send queue unavailable")
614                    })?;
615                outgoing
616                    .poller
617                    .notify()
618                    .map_err(|e| http_err("ws wake", e))?;
619                result.recv_timeout(ROUND_TIMEOUT).map_err(|_| {
620                    TransportError::new("transport.failed", "realtime send did not complete")
621                })?
622            })();
623            // A failed/timed-out write must not remain eligible for delivery
624            // behind a later command on the same connection.
625            if result.is_err() {
626                self.shutdown();
627            }
628            result
629        }
630    }
631
632    fn read_body(resp: ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, TransportError> {
633        resp.into_body()
634            .into_with_config()
635            .read_to_vec()
636            .map_err(|e| http_err("read", e))
637    }
638
639    impl Transport for NativeTransport {
640        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
641            self.post_sync("/sync", request)
642        }
643
644        fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
645            self.post_operation(request)
646        }
647
648        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
649            // §8.7 socket round: send the request as a `0x01`-tagged chunk on
650            // the connected socket and block for the reassembled response
651            // stream (the reader thread demuxes `0x01` chunks to END, routing
652            // any `0x00` delta / text that interleaves to the inbound queue).
653            // When no socket is connected this is the client's "not connected"
654            // path — the caller (client core) only calls `realtime_sync` while
655            // `realtime_connected`, and connect established the socket; a
656            // missing socket here means the round rides HTTP instead (same
657            // rule as the TS client: `POST /sync` when the socket is absent).
658            if self.outgoing.is_none() {
659                return self.post_sync("/sync", request);
660            }
661            let framed = self.round.begin(request)?;
662            if let Err(error) = self.send_message(Message::Binary(framed.into())) {
663                self.round.fail_in_flight(error);
664            }
665            self.round.wait()
666        }
667
668        fn download_segment(
669            &mut self,
670            request: &SegmentRequest,
671        ) -> Result<Vec<u8>, TransportError> {
672            // The FULL content address (`sha256:<hex>`) is the path param —
673            // the reference server keys its segment store by it (§5.1) and
674            // answers `sync.not_found` to a bare hex id. The requested-scopes
675            // header carries what the pull round granted; the server
676            // re-authorizes the download against it (§5.5) and answers
677            // `sync.forbidden` when it is missing.
678            let url = format!("{}/segments/{}", self.base_url, request.segment_id);
679            let mut req = self
680                .agent
681                .get(&url)
682                .header("x-syncular-scopes", &request.requested_scopes_json);
683            for (k, v) in &self.headers {
684                req = req.header(k.as_str(), v.as_str());
685            }
686            let resp = req.call().map_err(|e| http_err("GET segment", e))?;
687            read_body(resp)
688        }
689
690        fn supports_url_fetch(&self) -> bool {
691            self.signed_urls
692        }
693
694        fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
695            // §5.4: the URL is the entire grant — no host credentials attached.
696            self.get_bytes(url, false)
697        }
698
699        fn blob_upload(
700            &mut self,
701            blob_id: &str,
702            bytes: &[u8],
703            media_type: Option<&str>,
704        ) -> Result<(), TransportError> {
705            // Full `sha256:<hex>` id in the path — the reference server's
706            // isBlobId check rejects a bare hex id (§5.9.1).
707            let url = format!("{}/blobs/{}", self.base_url, blob_id);
708            let mut req = self.agent.put(&url).header(
709                "content-type",
710                media_type.unwrap_or("application/octet-stream"),
711            );
712            for (k, v) in &self.headers {
713                req = req.header(k.as_str(), v.as_str());
714            }
715            req.send(bytes).map_err(|e| http_err("PUT blob", e))?;
716            Ok(())
717        }
718
719        fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
720            // Full `sha256:<hex>` id in the path — the reference server's
721            // isBlobId check rejects a bare hex id (§5.9.1).
722            let url = format!("{}/blobs/{}", self.base_url, blob_id);
723            let mut req = self.agent.get(&url);
724            for (k, v) in &self.headers {
725                req = req.header(k.as_str(), v.as_str());
726            }
727            let resp = req.call().map_err(|e| {
728                let e = http_err("GET blob", e);
729                // Preserve a blob.* semantics hint (§5.9.5) for the caller.
730                if e.code == "transport.failed" {
731                    TransportError::new("blob.not_found", e.message)
732                } else {
733                    e
734                }
735            })?;
736            // §5.9.5 always-issue: a JSON body carries a presigned `url`; an
737            // octet-stream body is inline bytes.
738            let is_json = resp
739                .headers()
740                .get(ureq::http::header::CONTENT_TYPE)
741                .and_then(|value| value.to_str().ok())
742                .is_some_and(|value| value.contains("application/json"));
743            let body = read_body(resp)?;
744            if is_json {
745                if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
746                    if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
747                        return Ok(BlobDownload::Url {
748                            url: u.to_owned(),
749                            url_expires_at_ms: parsed
750                                .get("urlExpiresAtMs")
751                                .and_then(|v| v.as_i64()),
752                        });
753                    }
754                }
755            }
756            Ok(BlobDownload::Bytes(body))
757        }
758
759        fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
760            // §5.9.5: the URL is the entire grant — no host credentials.
761            self.get_bytes(url, false)
762        }
763
764        fn blob_upload_grant(
765            &mut self,
766            blob_id: &str,
767            byte_length: u64,
768            media_type: Option<&str>,
769        ) -> Result<BlobUploadGrant, TransportError> {
770            let url = format!("{}/blobs/{}/upload-grant", self.base_url, blob_id);
771            let mut req = self
772                .agent
773                .post(&url)
774                .header("content-type", "application/json");
775            for (k, v) in &self.headers {
776                req = req.header(k.as_str(), v.as_str());
777            }
778            let body = serde_json::json!({
779                "byteLength": byte_length,
780                "mediaType": media_type,
781            });
782            let resp = req
783                .send(body.to_string())
784                .map_err(|e| http_err("POST upload-grant", e))?;
785            let grant_body = read_body(resp)?;
786            let parsed: serde_json::Value = serde_json::from_slice(&grant_body)
787                .map_err(|e| TransportError::new("transport.failed", format!("read grant: {e}")))?;
788            if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
789                return Ok(BlobUploadGrant::Url {
790                    url: u.to_owned(),
791                    url_expires_at_ms: parsed.get("urlExpiresAtMs").and_then(|v| v.as_i64()),
792                });
793            }
794            if parsed.get("present").and_then(|v| v.as_bool()) == Some(true) {
795                return Ok(BlobUploadGrant::Present);
796            }
797            Ok(BlobUploadGrant::None)
798        }
799
800        fn blob_put_url(
801            &mut self,
802            url: &str,
803            bytes: &[u8],
804            media_type: Option<&str>,
805        ) -> Result<(), TransportError> {
806            // §5.9.3: the presigned URL is the entire grant — no host auth.
807            let req = self.agent.put(url).header(
808                "content-type",
809                media_type.unwrap_or("application/octet-stream"),
810            );
811            req.send(bytes).map_err(|e| http_err("PUT blob url", e))?;
812            Ok(())
813        }
814
815        fn realtime_connect(&mut self) -> Result<(), TransportError> {
816            if self.outgoing.is_some() {
817                return Ok(());
818            }
819            // Build the client request VIA `IntoClientRequest` so tungstenite
820            // fills the mandatory handshake headers (Host / Connection /
821            // Upgrade / Sec-WebSocket-Version / Sec-WebSocket-Key); a
822            // hand-built `http::Request` is taken as-is and would omit them.
823            // Then layer the configured auth/actor headers on top.
824            use tungstenite::client::IntoClientRequest;
825            let mut url = url::Url::parse(&self.ws_url)
826                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
827            if let Some(client_id) = self.realtime_client_id.as_deref() {
828                let retained_query: Vec<(String, String)> = url
829                    .query_pairs()
830                    .filter(|(key, _)| key != "clientId")
831                    .map(|(key, value)| (key.into_owned(), value.into_owned()))
832                    .collect();
833                url.set_query(None);
834                url.query_pairs_mut()
835                    .extend_pairs(retained_query)
836                    .append_pair("clientId", client_id);
837            }
838            let mut request = url
839                .as_str()
840                .into_client_request()
841                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
842            {
843                let out = request.headers_mut();
844                for (k, v) in &self.headers {
845                    if let (Ok(name), Ok(value)) = (
846                        tungstenite::http::HeaderName::try_from(k.as_str()),
847                        tungstenite::http::HeaderValue::try_from(v.as_str()),
848                    ) {
849                        out.insert(name, value);
850                    }
851                }
852            }
853            let (mut ws, _resp) = tungstenite::connect(request)
854                .map_err(|e| TransportError::new("transport.failed", format!("ws connect: {e}")))?;
855            let stream = match ws.get_mut() {
856                MaybeTlsStream::Plain(stream) => stream.try_clone(),
857                MaybeTlsStream::Rustls(stream) => stream.get_ref().try_clone(),
858                _ => {
859                    return Err(TransportError::new(
860                        "transport.failed",
861                        "unsupported websocket stream",
862                    ))
863                }
864            }
865            .map_err(|e| http_err("ws clone", e))?;
866            stream
867                .set_nonblocking(true)
868                .map_err(|e| http_err("ws nonblocking", e))?;
869            let poller = Arc::new(Poller::new().map_err(|e| http_err("ws poller", e))?);
870            // SAFETY: SocketRegistration owns this handle and unregisters it before drop.
871            unsafe { poller.add(&stream, Event::readable(0)) }
872                .map_err(|e| http_err("ws register", e))?;
873            let registration = SocketRegistration {
874                poller: Arc::clone(&poller),
875                stream,
876            };
877            let (outgoing, queued) = mpsc::sync_channel::<Outgoing>(1);
878            self.outgoing = Some(Box::new(SocketSender {
879                queue: outgoing,
880                poller,
881            }));
882            self.reader_stop.store(false, Ordering::SeqCst);
883            let inbound = Arc::clone(&self.inbound);
884            let stop = Arc::clone(&self.reader_stop);
885            let round = Arc::clone(&self.round);
886            self.reader = Some(std::thread::spawn(move || {
887                let mut events = Events::new();
888                let mut pending: Option<mpsc::Sender<Result<(), TransportError>>> = None;
889                let failure = 'io: loop {
890                    if stop.load(Ordering::SeqCst) {
891                        let _ = ws.close(None);
892                        let _ = ws.flush();
893                        break TransportError::new(
894                            "sync.transport_failed",
895                            "realtime disconnected mid-round (§8.7)",
896                        );
897                    }
898                    if pending.is_none() {
899                        if let Ok(outgoing) = queued.try_recv() {
900                            pending = Some(outgoing.completed);
901                            // WouldBlock retains the frame in tungstenite's write buffer.
902                            if let Err(error) = ws.write(outgoing.message) {
903                                if !is_would_block(&error) {
904                                    break http_err("ws write", error);
905                                }
906                            }
907                        }
908                    }
909                    // Bound receive work so a continuously readable peer cannot starve sends.
910                    let mut drained = false;
911                    for _ in 0..64 {
912                        match ws.read() {
913                            Ok(Message::Text(text)) => {
914                                inbound.push(Inbound::Text(text.to_string()))
915                            }
916                            Ok(Message::Binary(bytes)) => {
917                                if let Some(delta) = round.route_binary(&bytes) {
918                                    inbound.push(Inbound::Binary(delta));
919                                }
920                            }
921                            Err(error) if is_would_block(&error) => {
922                                drained = true;
923                                break;
924                            }
925                            Ok(Message::Close(_)) => {
926                                let _ = ws.flush();
927                                break 'io TransportError::new(
928                                    "sync.transport_failed",
929                                    "realtime disconnected mid-round (§8.7)",
930                                );
931                            }
932                            Err(_) => {
933                                break 'io TransportError::new(
934                                    "sync.transport_failed",
935                                    "realtime disconnected mid-round (§8.7)",
936                                );
937                            }
938                            Ok(_) => {}
939                        }
940                    }
941                    let interest = match ws.flush() {
942                        Ok(()) => {
943                            if let Some(completed) = pending.take() {
944                                let _ = completed.send(Ok(()));
945                            }
946                            Event::readable(0)
947                        }
948                        Err(error) if is_would_block(&error) => Event::all(0),
949                        Err(error) => break http_err("ws flush", error),
950                    };
951                    if !drained {
952                        continue;
953                    }
954                    if let Err(error) = registration.poller.modify(&registration.stream, interest) {
955                        break http_err("ws rearm", error);
956                    }
957                    events.clear();
958                    if let Err(error) = registration.poller.wait(&mut events, None) {
959                        if error.kind() != std::io::ErrorKind::Interrupted {
960                            break http_err("ws wait", error);
961                        }
962                    }
963                };
964                if let Some(completed) = pending {
965                    let _ = completed.send(Err(failure.clone()));
966                }
967                while let Ok(outgoing) = queued.try_recv() {
968                    let _ = outgoing.completed.send(Err(failure.clone()));
969                }
970                round.fail_in_flight(failure);
971            }));
972            Ok(())
973        }
974
975        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
976            self.send_message(Message::Text(text.to_owned().into()))
977        }
978
979        fn realtime_close(&mut self) -> Result<(), TransportError> {
980            self.shutdown();
981            Ok(())
982        }
983    }
984}