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(
194        &mut self,
195        request: &SegmentRequest,
196        on_progress: &mut dyn FnMut(u64),
197    ) -> Result<Vec<u8>, TransportError> {
198        match self {
199            HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
200            #[cfg(feature = "native-transport")]
201            HostTransport::Native(t) => t.download_segment(request, on_progress),
202        }
203    }
204
205    fn supports_url_fetch(&self) -> bool {
206        match self {
207            HostTransport::Null { signed_urls, .. } => *signed_urls,
208            #[cfg(feature = "native-transport")]
209            HostTransport::Native(t) => t.signed_urls,
210        }
211    }
212
213    fn fetch_url(
214        &mut self,
215        url: &str,
216        on_progress: &mut dyn FnMut(u64),
217    ) -> Result<Vec<u8>, TransportError> {
218        match self {
219            HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
220            #[cfg(feature = "native-transport")]
221            HostTransport::Native(t) => t.fetch_url(url, on_progress),
222        }
223    }
224
225    fn blob_upload(
226        &mut self,
227        blob_id: &str,
228        bytes: &[u8],
229        media_type: Option<&str>,
230    ) -> Result<(), TransportError> {
231        match self {
232            HostTransport::Null { .. } => Err(unavailable("blobUpload")),
233            #[cfg(feature = "native-transport")]
234            HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
235        }
236    }
237
238    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
239        match self {
240            HostTransport::Null { .. } => Err(unavailable("blobDownload")),
241            #[cfg(feature = "native-transport")]
242            HostTransport::Native(t) => t.blob_download(blob_id),
243        }
244    }
245
246    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
247        match self {
248            HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
249            #[cfg(feature = "native-transport")]
250            HostTransport::Native(t) => t.fetch_blob_url(url),
251        }
252    }
253
254    fn blob_upload_grant(
255        &mut self,
256        blob_id: &str,
257        byte_length: u64,
258        media_type: Option<&str>,
259    ) -> Result<BlobUploadGrant, TransportError> {
260        match self {
261            // No grant available ⇒ the client streams through the direct
262            // upload endpoint (§5.9.3 capability, not fallback).
263            HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
264            #[cfg(feature = "native-transport")]
265            HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
266        }
267    }
268
269    fn blob_put_url(
270        &mut self,
271        url: &str,
272        bytes: &[u8],
273        media_type: Option<&str>,
274    ) -> Result<(), TransportError> {
275        match self {
276            HostTransport::Null { .. } => Err(unavailable("blobPutUrl")),
277            #[cfg(feature = "native-transport")]
278            HostTransport::Native(t) => t.blob_put_url(url, bytes, media_type),
279        }
280    }
281
282    fn realtime_connect(&mut self) -> Result<(), TransportError> {
283        match self {
284            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
285            #[cfg(feature = "native-transport")]
286            HostTransport::Native(t) => {
287                t.set_realtime_client_id(None);
288                t.realtime_connect()
289            }
290        }
291    }
292
293    fn realtime_connect_for_client(&mut self, client_id: &str) -> Result<(), TransportError> {
294        match self {
295            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
296            #[cfg(feature = "native-transport")]
297            HostTransport::Native(t) => {
298                t.set_realtime_client_id(Some(client_id));
299                t.realtime_connect()
300            }
301        }
302    }
303
304    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
305        match self {
306            HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
307            #[cfg(feature = "native-transport")]
308            HostTransport::Native(t) => t.realtime_send(text),
309        }
310    }
311
312    fn realtime_close(&mut self) -> Result<(), TransportError> {
313        match self {
314            HostTransport::Null { .. } => Ok(()),
315            #[cfg(feature = "native-transport")]
316            HostTransport::Native(t) => t.realtime_close(),
317        }
318    }
319}
320
321#[cfg(feature = "native-transport")]
322mod native {
323    //! The real native HTTP + WS transport. HTTP via `ureq` (blocking, no
324    //! async runtime — matches the client's synchronous API); WS via
325    //! `tungstenite`, with a reader thread pushing inbound frames into the
326    //! shared `InboundBuffer`.
327    //!
328    //! Wire contract mirrors the reference HTTP+WS bindings (§1.1, §8.7):
329    //! `POST {baseUrl}/sync` (application/vnd.syncular.sync.v2, `X-Syncular-Scopes`
330    //! not needed here — the native app authenticates via configured headers),
331    //! `GET {baseUrl}/segments/{id}`, `PUT/GET {baseUrl}/blobs/{id}`, and the
332    //! realtime socket at `{wsUrl}` (ws(s):// derived from baseUrl).
333
334    use std::net::TcpStream;
335    use std::sync::atomic::{AtomicBool, Ordering};
336    use std::sync::{mpsc, Arc, Condvar, Mutex};
337    use std::thread::JoinHandle;
338    use std::time::Duration;
339
340    use polling::{Event, Events, Poller};
341    use tungstenite::stream::MaybeTlsStream;
342    use tungstenite::Message;
343
344    use super::{Inbound, InboundBuffer};
345    use crate::{
346        BlobDownload, BlobUploadGrant, RealtimeRound, RoundInbound, SegmentRequest, Transport,
347        TransportError,
348    };
349
350    // Unregister before the cloned socket handle is closed, including on unwind.
351    struct SocketRegistration {
352        poller: Arc<Poller>,
353        stream: TcpStream,
354    }
355
356    impl Drop for SocketRegistration {
357        fn drop(&mut self) {
358            let _ = self.poller.delete(&self.stream);
359        }
360    }
361
362    struct SocketSender {
363        queue: mpsc::SyncSender<Outgoing>,
364        poller: Arc<Poller>,
365    }
366
367    struct Outgoing {
368        message: Message,
369        completed: mpsc::Sender<Result<(), TransportError>>,
370    }
371
372    /// How long a single response round waits before giving up (§8.7 rounds
373    /// are bounded — bulk rides segments over HTTP). Generous; a stuck socket
374    /// surfaces as a transport failure rather than hanging the caller forever.
375    const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
376    /// The §8.7 round rendezvous shared between the reader thread (which
377    /// demuxes inbound `0x01` chunks into the round via [`RealtimeRound`]) and
378    /// `realtime_sync` (which begins the round, sends the request, and blocks
379    /// here for the reassembled response). The transport-agnostic framing
380    /// logic lives in [`RealtimeRound`] (the lean client crate, shared with
381    /// the Tauri plugin); this struct is just the thread rendezvous.
382    #[derive(Default)]
383    pub(super) struct RoundChannel {
384        state: Mutex<RoundState>,
385        ready: Condvar,
386    }
387
388    #[derive(Default)]
389    struct RoundState {
390        round: RealtimeRound,
391        /// The completed round outcome, taken by `realtime_sync` once set.
392        outcome: Option<Result<Vec<u8>, TransportError>>,
393    }
394
395    impl RoundChannel {
396        /// Begin a round: frame the request (`0x01` tag + envelope) for the
397        /// socket and mark it in flight. Errors if one is already in flight
398        /// (§8.7 one-in-flight, enforced client-side).
399        fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
400            let mut state = self.state.lock().expect("round lock");
401            state.outcome = None;
402            state.round.begin(request)
403        }
404
405        /// Route one inbound binary frame from the reader thread. Returns the
406        /// delta payload to enqueue on the inbound buffer, if any; a completed
407        /// or failed round is stored and the waiting `realtime_sync` woken.
408        fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
409            let mut state = self.state.lock().expect("round lock");
410            match state.round.route_binary(frame) {
411                Ok(RoundInbound::Delta(body)) => Some(body),
412                Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
413                Ok(RoundInbound::RoundComplete(bytes)) => {
414                    state.outcome = Some(Ok(bytes));
415                    self.ready.notify_all();
416                    None
417                }
418                Err(error) => {
419                    state.outcome = Some(Err(error));
420                    self.ready.notify_all();
421                    None
422                }
423            }
424        }
425
426        /// Fail any in-flight round (socket dropped) and wake the waiter.
427        fn fail_in_flight(&self, error: TransportError) {
428            let mut state = self.state.lock().expect("round lock");
429            if state.round.in_flight() && state.outcome.is_none() {
430                state.round.abort();
431                state.outcome = Some(Err(error));
432                self.ready.notify_all();
433            }
434        }
435
436        /// Block until the round completes, fails, or `ROUND_TIMEOUT` elapses.
437        fn wait(&self) -> Result<Vec<u8>, TransportError> {
438            let mut state = self.state.lock().expect("round lock");
439            let deadline = std::time::Instant::now() + ROUND_TIMEOUT;
440            while state.outcome.is_none() {
441                let now = std::time::Instant::now();
442                if now >= deadline {
443                    state.round.abort();
444                    return Err(TransportError::new(
445                        "sync.transport_failed",
446                        "realtime sync round timed out (§8.7)",
447                    ));
448                }
449                let (guard, _timeout) = self
450                    .ready
451                    .wait_timeout(state, deadline - now)
452                    .expect("round wait");
453                state = guard;
454            }
455            state.outcome.take().expect("outcome present")
456        }
457    }
458
459    fn http_err(op: &str, e: impl std::fmt::Display) -> TransportError {
460        TransportError::new("transport.failed", format!("{op}: {e}"))
461    }
462
463    /// Nonblocking I/O needs a readiness notification before it can continue.
464    fn is_would_block(e: &tungstenite::Error) -> bool {
465        matches!(
466            e,
467            tungstenite::Error::Io(io) if matches!(
468                io.kind(),
469                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
470            )
471        )
472    }
473
474    pub struct NativeTransport {
475        base_url: String,
476        ws_url: String,
477        /// Extra request headers (auth, actor/project ids) as (name, value).
478        headers: Vec<(String, String)>,
479        agent: ureq::Agent,
480        pub signed_urls: bool,
481        pub inbound: Arc<InboundBuffer>,
482        /// One I/O thread owns the socket; callers wait for their write to flush.
483        outgoing: Option<Box<SocketSender>>,
484        reader: Option<JoinHandle<()>>,
485        reader_stop: Arc<AtomicBool>,
486        /// §8.7 round rendezvous, shared with the reader thread.
487        round: Arc<RoundChannel>,
488        realtime_client_id: Option<String>,
489    }
490
491    fn derive_ws_url(base_url: &str) -> String {
492        // {scheme}://host/path → ws(s)://host/path/realtime — the reference
493        // realtime endpoint sits alongside /sync under the mount (§8.7).
494        let ws = if let Some(rest) = base_url.strip_prefix("https://") {
495            format!("wss://{rest}")
496        } else if let Some(rest) = base_url.strip_prefix("http://") {
497            format!("ws://{rest}")
498        } else {
499            base_url.to_owned()
500        };
501        let trimmed = ws.trim_end_matches('/');
502        format!("{trimmed}/realtime")
503    }
504
505    impl NativeTransport {
506        pub fn new(
507            base_url: &str,
508            config: &serde_json::Value,
509            notify: Option<Arc<dyn Fn() + Send + Sync>>,
510        ) -> Result<Self, String> {
511            let mut headers = Vec::new();
512            if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
513                for (k, v) in map {
514                    if let Some(s) = v.as_str() {
515                        headers.push((k.clone(), s.to_owned()));
516                    }
517                }
518            }
519            let ws_url = config
520                .get("wsUrl")
521                .and_then(|v| v.as_str())
522                .map(str::to_owned)
523                .unwrap_or_else(|| derive_ws_url(base_url));
524            Ok(NativeTransport {
525                base_url: base_url.trim_end_matches('/').to_owned(),
526                ws_url,
527                headers,
528                agent: ureq::Agent::new_with_defaults(),
529                signed_urls: false,
530                inbound: Arc::new(match notify {
531                    Some(notify) => InboundBuffer::with_notify(notify),
532                    None => InboundBuffer::default(),
533                }),
534                outgoing: None,
535                reader: None,
536                reader_stop: Arc::new(AtomicBool::new(false)),
537                round: Arc::new(RoundChannel::default()),
538                realtime_client_id: None,
539            })
540        }
541
542        fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
543            let url = format!("{}{}", self.base_url, path);
544            // SSP2 requests carry their own media type (SPEC 1.1); a stock
545            // server answers 415 to anything else.
546            let mut req = self
547                .agent
548                .post(&url)
549                .header("content-type", "application/vnd.syncular.sync.v2");
550            for (k, v) in &self.headers {
551                req = req.header(k.as_str(), v.as_str());
552            }
553            let resp = req.send(body).map_err(|e| http_err("POST", e))?;
554            read_body(resp, None)
555        }
556
557        fn post_operation(&self, body: &[u8]) -> Result<Vec<u8>, TransportError> {
558            let url = format!("{}/operations", self.base_url);
559            let mut req = self.agent.post(&url).header(
560                "content-type",
561                "application/vnd.syncular.operations.v1+json",
562            );
563            for (key, value) in &self.headers {
564                req = req.header(key.as_str(), value.as_str());
565            }
566            let response = req
567                .send(body)
568                .map_err(|error| http_err("POST operation", error))?;
569            read_body(response, None)
570        }
571
572        fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
573            let mut req = self.agent.get(url);
574            if with_headers {
575                for (k, v) in &self.headers {
576                    req = req.header(k.as_str(), v.as_str());
577                }
578            }
579            let resp = req.call().map_err(|e| http_err("GET", e))?;
580            read_body(resp, None)
581        }
582
583        pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
584            self.headers = headers;
585        }
586
587        pub fn set_realtime_client_id(&mut self, client_id: Option<&str>) {
588            self.realtime_client_id = client_id.map(str::to_owned);
589        }
590
591        pub fn shutdown(&mut self) {
592            self.reader_stop.store(true, Ordering::SeqCst);
593            // Wake any `realtime_sync` blocked on a round: the socket is going
594            // away, so the round can never complete (§8.7 mid-round drop).
595            self.round.fail_in_flight(TransportError::new(
596                "sync.transport_failed",
597                "realtime disconnected mid-round (§8.7)",
598            ));
599            if let Some(outgoing) = &self.outgoing {
600                let _ = outgoing.poller.notify();
601            }
602            if let Some(handle) = self.reader.take() {
603                let _ = handle.join();
604            }
605            self.outgoing = None;
606        }
607
608        fn send_message(&mut self, message: Message) -> Result<(), TransportError> {
609            let result = (|| {
610                let Some(outgoing) = &self.outgoing else {
611                    return Err(TransportError::new(
612                        "transport.failed",
613                        "realtime not connected",
614                    ));
615                };
616                let (completed, result) = mpsc::channel();
617                outgoing
618                    .queue
619                    .try_send(Outgoing { message, completed })
620                    .map_err(|_| {
621                        TransportError::new("transport.failed", "realtime send queue unavailable")
622                    })?;
623                outgoing
624                    .poller
625                    .notify()
626                    .map_err(|e| http_err("ws wake", e))?;
627                result.recv_timeout(ROUND_TIMEOUT).map_err(|_| {
628                    TransportError::new("transport.failed", "realtime send did not complete")
629                })?
630            })();
631            // A failed/timed-out write must not remain eligible for delivery
632            // behind a later command on the same connection.
633            if result.is_err() {
634                self.shutdown();
635            }
636            result
637        }
638    }
639
640    fn read_body(
641        resp: ureq::http::Response<ureq::Body>,
642        mut progress: Option<&mut dyn FnMut(u64)>,
643    ) -> Result<Vec<u8>, TransportError> {
644        use std::io::Read;
645        let mut reader = resp.into_body().into_with_config().reader();
646        let mut bytes = Vec::new();
647        let mut chunk = [0u8; 64 * 1024];
648        let mut reported = 0;
649        loop {
650            let n = reader.read(&mut chunk).map_err(|e| http_err("read", e))?;
651            if n == 0 {
652                break;
653            }
654            bytes.extend_from_slice(&chunk[..n]);
655            if bytes.len() - reported >= 64 * 1024 {
656                if let Some(callback) = progress.as_mut() {
657                    callback(bytes.len() as u64);
658                }
659                reported = bytes.len();
660            }
661        }
662        if bytes.len() != reported {
663            if let Some(callback) = progress {
664                callback(bytes.len() as u64);
665            }
666        }
667        Ok(bytes)
668    }
669
670    impl Transport for NativeTransport {
671        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
672            self.post_sync("/sync", request)
673        }
674
675        fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
676            self.post_operation(request)
677        }
678
679        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
680            // §8.7 socket round: send the request as a `0x01`-tagged chunk on
681            // the connected socket and block for the reassembled response
682            // stream (the reader thread demuxes `0x01` chunks to END, routing
683            // any `0x00` delta / text that interleaves to the inbound queue).
684            // When no socket is connected this is the client's "not connected"
685            // path — the caller (client core) only calls `realtime_sync` while
686            // `realtime_connected`, and connect established the socket; a
687            // missing socket here means the round rides HTTP instead (same
688            // rule as the TS client: `POST /sync` when the socket is absent).
689            if self.outgoing.is_none() {
690                return self.post_sync("/sync", request);
691            }
692            let framed = self.round.begin(request)?;
693            if let Err(error) = self.send_message(Message::Binary(framed.into())) {
694                self.round.fail_in_flight(error);
695            }
696            self.round.wait()
697        }
698
699        fn download_segment(
700            &mut self,
701            request: &SegmentRequest,
702            on_progress: &mut dyn FnMut(u64),
703        ) -> Result<Vec<u8>, TransportError> {
704            // The FULL content address (`sha256:<hex>`) is the path param —
705            // the reference server keys its segment store by it (§5.1) and
706            // answers `sync.not_found` to a bare hex id. The requested-scopes
707            // header carries what the pull round granted; the server
708            // re-authorizes the download against it (§5.5) and answers
709            // `sync.forbidden` when it is missing.
710            let url = format!("{}/segments/{}", self.base_url, request.segment_id);
711            let mut req = self
712                .agent
713                .get(&url)
714                .header("x-syncular-scopes", &request.requested_scopes_json);
715            for (k, v) in &self.headers {
716                req = req.header(k.as_str(), v.as_str());
717            }
718            let resp = req.call().map_err(|e| http_err("GET segment", e))?;
719            read_body(resp, Some(on_progress))
720        }
721
722        fn supports_url_fetch(&self) -> bool {
723            self.signed_urls
724        }
725
726        fn fetch_url(
727            &mut self,
728            url: &str,
729            on_progress: &mut dyn FnMut(u64),
730        ) -> Result<Vec<u8>, TransportError> {
731            // §5.4: the URL is the entire grant — no host credentials attached.
732            read_body(
733                self.agent
734                    .get(url)
735                    .call()
736                    .map_err(|e| http_err("GET segment URL", e))?,
737                Some(on_progress),
738            )
739        }
740
741        fn blob_upload(
742            &mut self,
743            blob_id: &str,
744            bytes: &[u8],
745            media_type: Option<&str>,
746        ) -> Result<(), TransportError> {
747            // Full `sha256:<hex>` id in the path — the reference server's
748            // isBlobId check rejects a bare hex id (§5.9.1).
749            let url = format!("{}/blobs/{}", self.base_url, blob_id);
750            let mut req = self.agent.put(&url).header(
751                "content-type",
752                media_type.unwrap_or("application/octet-stream"),
753            );
754            for (k, v) in &self.headers {
755                req = req.header(k.as_str(), v.as_str());
756            }
757            req.send(bytes).map_err(|e| http_err("PUT blob", e))?;
758            Ok(())
759        }
760
761        fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
762            // Full `sha256:<hex>` id in the path — the reference server's
763            // isBlobId check rejects a bare hex id (§5.9.1).
764            let url = format!("{}/blobs/{}", self.base_url, blob_id);
765            let mut req = self.agent.get(&url);
766            for (k, v) in &self.headers {
767                req = req.header(k.as_str(), v.as_str());
768            }
769            let resp = req.call().map_err(|e| {
770                let e = http_err("GET blob", e);
771                // Preserve a blob.* semantics hint (§5.9.5) for the caller.
772                if e.code == "transport.failed" {
773                    TransportError::new("blob.not_found", e.message)
774                } else {
775                    e
776                }
777            })?;
778            // §5.9.5 always-issue: a JSON body carries a presigned `url`; an
779            // octet-stream body is inline bytes.
780            let is_json = resp
781                .headers()
782                .get(ureq::http::header::CONTENT_TYPE)
783                .and_then(|value| value.to_str().ok())
784                .is_some_and(|value| value.contains("application/json"));
785            let body = read_body(resp, None)?;
786            if is_json {
787                if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
788                    if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
789                        return Ok(BlobDownload::Url {
790                            url: u.to_owned(),
791                            url_expires_at_ms: parsed
792                                .get("urlExpiresAtMs")
793                                .and_then(|v| v.as_i64()),
794                        });
795                    }
796                }
797            }
798            Ok(BlobDownload::Bytes(body))
799        }
800
801        fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
802            // §5.9.5: the URL is the entire grant — no host credentials.
803            self.get_bytes(url, false)
804        }
805
806        fn blob_upload_grant(
807            &mut self,
808            blob_id: &str,
809            byte_length: u64,
810            media_type: Option<&str>,
811        ) -> Result<BlobUploadGrant, TransportError> {
812            let url = format!("{}/blobs/{}/upload-grant", self.base_url, blob_id);
813            let mut req = self
814                .agent
815                .post(&url)
816                .header("content-type", "application/json");
817            for (k, v) in &self.headers {
818                req = req.header(k.as_str(), v.as_str());
819            }
820            let body = serde_json::json!({
821                "byteLength": byte_length,
822                "mediaType": media_type,
823            });
824            let resp = req
825                .send(body.to_string())
826                .map_err(|e| http_err("POST upload-grant", e))?;
827            let grant_body = read_body(resp, None)?;
828            let parsed: serde_json::Value = serde_json::from_slice(&grant_body)
829                .map_err(|e| TransportError::new("transport.failed", format!("read grant: {e}")))?;
830            if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
831                return Ok(BlobUploadGrant::Url {
832                    url: u.to_owned(),
833                    url_expires_at_ms: parsed.get("urlExpiresAtMs").and_then(|v| v.as_i64()),
834                });
835            }
836            if parsed.get("present").and_then(|v| v.as_bool()) == Some(true) {
837                return Ok(BlobUploadGrant::Present);
838            }
839            Ok(BlobUploadGrant::None)
840        }
841
842        fn blob_put_url(
843            &mut self,
844            url: &str,
845            bytes: &[u8],
846            media_type: Option<&str>,
847        ) -> Result<(), TransportError> {
848            // §5.9.3: the presigned URL is the entire grant — no host auth.
849            let req = self.agent.put(url).header(
850                "content-type",
851                media_type.unwrap_or("application/octet-stream"),
852            );
853            req.send(bytes).map_err(|e| http_err("PUT blob url", e))?;
854            Ok(())
855        }
856
857        fn realtime_connect(&mut self) -> Result<(), TransportError> {
858            if self.outgoing.is_some() {
859                return Ok(());
860            }
861            // Build the client request VIA `IntoClientRequest` so tungstenite
862            // fills the mandatory handshake headers (Host / Connection /
863            // Upgrade / Sec-WebSocket-Version / Sec-WebSocket-Key); a
864            // hand-built `http::Request` is taken as-is and would omit them.
865            // Then layer the configured auth/actor headers on top.
866            use tungstenite::client::IntoClientRequest;
867            let mut url = url::Url::parse(&self.ws_url)
868                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
869            if let Some(client_id) = self.realtime_client_id.as_deref() {
870                let retained_query: Vec<(String, String)> = url
871                    .query_pairs()
872                    .filter(|(key, _)| key != "clientId")
873                    .map(|(key, value)| (key.into_owned(), value.into_owned()))
874                    .collect();
875                url.set_query(None);
876                url.query_pairs_mut()
877                    .extend_pairs(retained_query)
878                    .append_pair("clientId", client_id);
879            }
880            let mut request = url
881                .as_str()
882                .into_client_request()
883                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
884            {
885                let out = request.headers_mut();
886                for (k, v) in &self.headers {
887                    if let (Ok(name), Ok(value)) = (
888                        tungstenite::http::HeaderName::try_from(k.as_str()),
889                        tungstenite::http::HeaderValue::try_from(v.as_str()),
890                    ) {
891                        out.insert(name, value);
892                    }
893                }
894            }
895            let (mut ws, _resp) = tungstenite::connect(request)
896                .map_err(|e| TransportError::new("transport.failed", format!("ws connect: {e}")))?;
897            let stream = match ws.get_mut() {
898                MaybeTlsStream::Plain(stream) => stream.try_clone(),
899                MaybeTlsStream::Rustls(stream) => stream.get_ref().try_clone(),
900                _ => {
901                    return Err(TransportError::new(
902                        "transport.failed",
903                        "unsupported websocket stream",
904                    ))
905                }
906            }
907            .map_err(|e| http_err("ws clone", e))?;
908            stream
909                .set_nonblocking(true)
910                .map_err(|e| http_err("ws nonblocking", e))?;
911            let poller = Arc::new(Poller::new().map_err(|e| http_err("ws poller", e))?);
912            // SAFETY: SocketRegistration owns this handle and unregisters it before drop.
913            unsafe { poller.add(&stream, Event::readable(0)) }
914                .map_err(|e| http_err("ws register", e))?;
915            let registration = SocketRegistration {
916                poller: Arc::clone(&poller),
917                stream,
918            };
919            let (outgoing, queued) = mpsc::sync_channel::<Outgoing>(1);
920            self.outgoing = Some(Box::new(SocketSender {
921                queue: outgoing,
922                poller,
923            }));
924            self.reader_stop.store(false, Ordering::SeqCst);
925            let inbound = Arc::clone(&self.inbound);
926            let stop = Arc::clone(&self.reader_stop);
927            let round = Arc::clone(&self.round);
928            self.reader = Some(std::thread::spawn(move || {
929                let mut events = Events::new();
930                let mut pending: Option<mpsc::Sender<Result<(), TransportError>>> = None;
931                let failure = 'io: loop {
932                    if stop.load(Ordering::SeqCst) {
933                        let _ = ws.close(None);
934                        let _ = ws.flush();
935                        break TransportError::new(
936                            "sync.transport_failed",
937                            "realtime disconnected mid-round (§8.7)",
938                        );
939                    }
940                    if pending.is_none() {
941                        if let Ok(outgoing) = queued.try_recv() {
942                            pending = Some(outgoing.completed);
943                            // WouldBlock retains the frame in tungstenite's write buffer.
944                            if let Err(error) = ws.write(outgoing.message) {
945                                if !is_would_block(&error) {
946                                    break http_err("ws write", error);
947                                }
948                            }
949                        }
950                    }
951                    // Bound receive work so a continuously readable peer cannot starve sends.
952                    let mut drained = false;
953                    for _ in 0..64 {
954                        match ws.read() {
955                            Ok(Message::Text(text)) => {
956                                inbound.push(Inbound::Text(text.to_string()))
957                            }
958                            Ok(Message::Binary(bytes)) => {
959                                if let Some(delta) = round.route_binary(&bytes) {
960                                    inbound.push(Inbound::Binary(delta));
961                                }
962                            }
963                            Err(error) if is_would_block(&error) => {
964                                drained = true;
965                                break;
966                            }
967                            Ok(Message::Close(_)) => {
968                                let _ = ws.flush();
969                                break 'io TransportError::new(
970                                    "sync.transport_failed",
971                                    "realtime disconnected mid-round (§8.7)",
972                                );
973                            }
974                            Err(_) => {
975                                break 'io TransportError::new(
976                                    "sync.transport_failed",
977                                    "realtime disconnected mid-round (§8.7)",
978                                );
979                            }
980                            Ok(_) => {}
981                        }
982                    }
983                    let interest = match ws.flush() {
984                        Ok(()) => {
985                            if let Some(completed) = pending.take() {
986                                let _ = completed.send(Ok(()));
987                            }
988                            Event::readable(0)
989                        }
990                        Err(error) if is_would_block(&error) => Event::all(0),
991                        Err(error) => break http_err("ws flush", error),
992                    };
993                    if !drained {
994                        continue;
995                    }
996                    if let Err(error) = registration.poller.modify(&registration.stream, interest) {
997                        break http_err("ws rearm", error);
998                    }
999                    events.clear();
1000                    if let Err(error) = registration.poller.wait(&mut events, None) {
1001                        if error.kind() != std::io::ErrorKind::Interrupted {
1002                            break http_err("ws wait", error);
1003                        }
1004                    }
1005                };
1006                if let Some(completed) = pending {
1007                    let _ = completed.send(Err(failure.clone()));
1008                }
1009                while let Ok(outgoing) = queued.try_recv() {
1010                    let _ = outgoing.completed.send(Err(failure.clone()));
1011                }
1012                round.fail_in_flight(failure);
1013            }));
1014            Ok(())
1015        }
1016
1017        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1018            self.send_message(Message::Text(text.to_owned().into()))
1019        }
1020
1021        fn realtime_close(&mut self) -> Result<(), TransportError> {
1022            self.shutdown();
1023            Ok(())
1024        }
1025    }
1026}
1027
1028#[cfg(all(test, feature = "native-transport"))]
1029mod tests {
1030    use super::*;
1031    use std::io::{BufRead, BufReader, Write};
1032
1033    #[test]
1034    fn segment_http_reports_intermediate_bytes_for_direct_and_signed_urls() {
1035        for signed in [false, true] {
1036            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1037            let base = format!("http://{}", listener.local_addr().unwrap());
1038            let server = std::thread::spawn(move || {
1039                let (mut socket, _) = listener.accept().unwrap();
1040                let mut reader = BufReader::new(socket.try_clone().unwrap());
1041                let mut headers = String::new();
1042                loop {
1043                    let mut line = String::new();
1044                    reader.read_line(&mut line).unwrap();
1045                    if line == "\r\n" {
1046                        break;
1047                    }
1048                    assert!(!line.is_empty());
1049                    headers.push_str(&line.to_ascii_lowercase());
1050                }
1051                assert_eq!(headers.contains("authorization: bearer test"), !signed);
1052                assert_eq!(headers.contains("x-syncular-scopes:"), !signed);
1053                let bytes = vec![7; 128 * 1024 + 17];
1054                write!(
1055                    socket,
1056                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1057                    bytes.len()
1058                )
1059                .unwrap();
1060                socket.write_all(&bytes).unwrap();
1061            });
1062            let mut transport = HostTransport::from_config(
1063                &serde_json::json!({"baseUrl": base, "headers": {"authorization": "Bearer test"}}),
1064            )
1065            .unwrap();
1066            let mut updates = Vec::new();
1067            let mut progress = |bytes| updates.push(bytes);
1068            let bytes = if signed {
1069                transport
1070                    .fetch_url(&format!("{base}/signed"), &mut progress)
1071                    .unwrap()
1072            } else {
1073                transport
1074                    .download_segment(
1075                        &SegmentRequest {
1076                            segment_id: "sha256:test".into(),
1077                            table: "tasks".into(),
1078                            requested_scopes_json: "{}".into(),
1079                        },
1080                        &mut progress,
1081                    )
1082                    .unwrap()
1083            };
1084            assert_eq!(bytes.len(), 128 * 1024 + 17);
1085            assert!(updates.iter().any(|n| *n > 0 && *n < bytes.len() as u64));
1086            assert_eq!(updates.last(), Some(&(bytes.len() as u64)));
1087            assert!(updates.windows(2).all(|pair| pair[0] < pair[1]));
1088            server.join().unwrap();
1089        }
1090    }
1091}