Skip to main content

tauri_plugin_syncular/
transport.rs

1//! The transport the plugin core OWNS — the same two-shape design as the FFI
2//! crate's transport (a native app has no host loop to invert transport into,
3//! unlike the conformance shim, so the Rust core drives the network itself).
4//!
5//! - `Null` (dependency-lean default): every network op fails loudly with
6//!   `transport.unavailable`; client-local commands (create/subscribe/mutate/
7//!   query/…) still run. Enough for the unit tests and an offline app.
8//! - `Native` (the `native-transport` feature): a real blocking HTTP (`ureq`)
9//!   plus WS (`tungstenite`) client with a reader thread buffering inbound
10//!   frames. This is what apps get so sync/segments/blobs/realtime all work.
11//!
12//! Kept deliberately parallel to `rust/crates/ffi/src/transport.rs`; the only
13//! difference is that this one carries no `EventQueue` (the plugin core derives
14//! events from client state after each drain, so the transport just buffers raw
15//! inbound frames).
16
17use std::sync::Mutex;
18
19use syncular_client::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
20
21/// One inbound realtime frame buffered for the client's `on_realtime_*`.
22pub enum Inbound {
23    Text(String),
24    Binary(Vec<u8>),
25}
26
27/// The shared inbound buffer the WS reader thread fills and the command path
28/// drains after each command.
29#[derive(Default)]
30pub struct InboundBuffer {
31    frames: Mutex<Vec<Inbound>>,
32}
33
34impl InboundBuffer {
35    pub fn push(&self, frame: Inbound) {
36        self.frames.lock().expect("inbound lock").push(frame);
37    }
38    fn take(&self) -> Vec<Inbound> {
39        std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
40    }
41}
42
43pub enum HostTransport {
44    /// No network: client-local commands only (dependency-lean default).
45    Null {
46        signed_urls: bool,
47        inbound: std::sync::Arc<InboundBuffer>,
48    },
49    #[cfg(feature = "native-transport")]
50    Native(native::NativeTransport),
51}
52
53impl HostTransport {
54    /// Build the transport from the plugin config. `{}` (or no `baseUrl`) →
55    /// `Null`; a `baseUrl` under the `native-transport` feature → `Native`.
56    pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
57        #[cfg(feature = "native-transport")]
58        {
59            if let Some(base_url) = config.get("baseUrl").and_then(|v| v.as_str()) {
60                return Ok(HostTransport::Native(native::NativeTransport::new(
61                    base_url, config,
62                )?));
63            }
64        }
65        #[cfg(not(feature = "native-transport"))]
66        {
67            if config.get("baseUrl").is_some() {
68                return Err("this build has no native transport (enable the \
69                    native-transport feature on tauri-plugin-syncular)"
70                    .to_owned());
71            }
72        }
73        Ok(HostTransport::Null {
74            signed_urls: false,
75            inbound: std::sync::Arc::new(InboundBuffer::default()),
76        })
77    }
78
79    pub fn set_signed_urls(&mut self, value: bool) {
80        match self {
81            HostTransport::Null { signed_urls, .. } => *signed_urls = value,
82            #[cfg(feature = "native-transport")]
83            HostTransport::Native(t) => t.signed_urls = value,
84        }
85    }
86
87    /// Replace the extra request headers at runtime (rotating auth — RFC 0002
88    /// §2.3). HTTP requests use the new set from the next call; the WS
89    /// handshake reads headers at connect, so a live realtime socket keeps
90    /// its old set until it reconnects. A `Null` transport accepts and
91    /// ignores the set (it never sends requests).
92    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
93        match self {
94            HostTransport::Null { .. } => drop(headers),
95            #[cfg(feature = "native-transport")]
96            HostTransport::Native(t) => t.set_headers(headers),
97        }
98    }
99
100    /// Drain the inbound realtime frames buffered since the last call.
101    pub fn take_inbound(&mut self) -> Vec<Inbound> {
102        match self {
103            HostTransport::Null { inbound, .. } => inbound.take(),
104            #[cfg(feature = "native-transport")]
105            HostTransport::Native(t) => t.inbound.take(),
106        }
107    }
108
109    /// Release the socket/reader thread. Idempotent.
110    pub fn shutdown(&mut self) {
111        match self {
112            HostTransport::Null { .. } => {}
113            #[cfg(feature = "native-transport")]
114            HostTransport::Native(t) => t.shutdown(),
115        }
116    }
117}
118
119fn unavailable(op: &str) -> TransportError {
120    TransportError::new(
121        "transport.unavailable",
122        format!("{op} needs the native transport (enable the native-transport feature)"),
123    )
124}
125
126#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
127impl Transport for HostTransport {
128    fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
129        match self {
130            HostTransport::Null { .. } => Err(unavailable("sync")),
131            #[cfg(feature = "native-transport")]
132            HostTransport::Native(t) => t.sync(request),
133        }
134    }
135
136    fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
137        match self {
138            HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
139            #[cfg(feature = "native-transport")]
140            HostTransport::Native(t) => t.realtime_sync(request),
141        }
142    }
143
144    fn download_segment(&mut self, request: &SegmentRequest) -> Result<Vec<u8>, TransportError> {
145        match self {
146            HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
147            #[cfg(feature = "native-transport")]
148            HostTransport::Native(t) => t.download_segment(request),
149        }
150    }
151
152    fn supports_url_fetch(&self) -> bool {
153        match self {
154            HostTransport::Null { signed_urls, .. } => *signed_urls,
155            #[cfg(feature = "native-transport")]
156            HostTransport::Native(t) => t.signed_urls,
157        }
158    }
159
160    fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
161        match self {
162            HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
163            #[cfg(feature = "native-transport")]
164            HostTransport::Native(t) => t.fetch_url(url),
165        }
166    }
167
168    fn blob_upload(
169        &mut self,
170        blob_id: &str,
171        bytes: &[u8],
172        media_type: Option<&str>,
173    ) -> Result<(), TransportError> {
174        match self {
175            HostTransport::Null { .. } => Err(unavailable("blobUpload")),
176            #[cfg(feature = "native-transport")]
177            HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
178        }
179    }
180
181    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
182        match self {
183            HostTransport::Null { .. } => Err(unavailable("blobDownload")),
184            #[cfg(feature = "native-transport")]
185            HostTransport::Native(t) => t.blob_download(blob_id),
186        }
187    }
188
189    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
190        match self {
191            HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
192            #[cfg(feature = "native-transport")]
193            HostTransport::Native(t) => t.fetch_blob_url(url),
194        }
195    }
196
197    fn blob_upload_grant(
198        &mut self,
199        blob_id: &str,
200        byte_length: u64,
201        media_type: Option<&str>,
202    ) -> Result<BlobUploadGrant, TransportError> {
203        match self {
204            HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
205            #[cfg(feature = "native-transport")]
206            HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
207        }
208    }
209
210    fn realtime_connect(&mut self) -> Result<(), TransportError> {
211        match self {
212            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
213            #[cfg(feature = "native-transport")]
214            HostTransport::Native(t) => t.realtime_connect(),
215        }
216    }
217
218    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
219        match self {
220            HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
221            #[cfg(feature = "native-transport")]
222            HostTransport::Native(t) => t.realtime_send(text),
223        }
224    }
225
226    fn realtime_close(&mut self) -> Result<(), TransportError> {
227        match self {
228            HostTransport::Null { .. } => Ok(()),
229            #[cfg(feature = "native-transport")]
230            HostTransport::Native(t) => t.realtime_close(),
231        }
232    }
233}
234
235#[cfg(feature = "native-transport")]
236mod native {
237    //! The real native HTTP + WS transport (mirrors the FFI crate's `native`
238    //! module: `ureq` blocking HTTP, `tungstenite` sync WS, a reader thread
239    //! buffering inbound frames — matching the client's synchronous API).
240
241    use std::io::Read;
242    use std::net::TcpStream;
243    use std::sync::atomic::{AtomicBool, Ordering};
244    use std::sync::{Arc, Condvar, Mutex};
245    use std::thread::JoinHandle;
246    use std::time::Duration;
247
248    use tungstenite::stream::MaybeTlsStream;
249    use tungstenite::{Message, WebSocket};
250
251    use super::{Inbound, InboundBuffer};
252    use syncular_client::{
253        BlobDownload, RealtimeRound, RoundInbound, SegmentRequest, Transport, TransportError,
254    };
255
256    type Ws = WebSocket<MaybeTlsStream<TcpStream>>;
257
258    // §8.7 round-over-socket framing — kept byte-for-byte parallel with
259    // `rust/crates/ffi/src/transport.rs`'s `native` module (see that file for
260    // the full commentary). The transport-agnostic tag demux + reassembly
261    // lives in `syncular_client::RealtimeRound`, shared by both crates; only
262    // the WS send/read plumbing is mirrored here (the crates are in separate
263    // cargo workspaces and cannot share a private module).
264    const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
265    const READ_TIMEOUT: Duration = Duration::from_millis(50);
266
267    /// The §8.7 round rendezvous shared between the reader thread and
268    /// `realtime_sync`. See the FFI crate's `RoundChannel` for details.
269    #[derive(Default)]
270    pub(super) struct RoundChannel {
271        state: Mutex<RoundState>,
272        ready: Condvar,
273    }
274
275    #[derive(Default)]
276    struct RoundState {
277        round: RealtimeRound,
278        outcome: Option<Result<Vec<u8>, TransportError>>,
279    }
280
281    impl RoundChannel {
282        fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
283            let mut state = self.state.lock().expect("round lock");
284            state.outcome = None;
285            state.round.begin(request)
286        }
287
288        fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
289            let mut state = self.state.lock().expect("round lock");
290            match state.round.route_binary(frame) {
291                Ok(RoundInbound::Delta(body)) => Some(body),
292                Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
293                Ok(RoundInbound::RoundComplete(bytes)) => {
294                    state.outcome = Some(Ok(bytes));
295                    self.ready.notify_all();
296                    None
297                }
298                Err(error) => {
299                    state.outcome = Some(Err(error));
300                    self.ready.notify_all();
301                    None
302                }
303            }
304        }
305
306        fn fail_in_flight(&self, error: TransportError) {
307            let mut state = self.state.lock().expect("round lock");
308            if state.round.in_flight() && state.outcome.is_none() {
309                state.round.abort();
310                state.outcome = Some(Err(error));
311                self.ready.notify_all();
312            }
313        }
314
315        fn wait(&self) -> Result<Vec<u8>, TransportError> {
316            let mut state = self.state.lock().expect("round lock");
317            let deadline = std::time::Instant::now() + ROUND_TIMEOUT;
318            while state.outcome.is_none() {
319                let now = std::time::Instant::now();
320                if now >= deadline {
321                    state.round.abort();
322                    return Err(TransportError::new(
323                        "sync.transport_failed",
324                        "realtime sync round timed out (§8.7)",
325                    ));
326                }
327                let (guard, _timeout) = self
328                    .ready
329                    .wait_timeout(state, deadline - now)
330                    .expect("round wait");
331                state = guard;
332            }
333            state.outcome.take().expect("outcome present")
334        }
335    }
336
337    fn is_would_block(e: &tungstenite::Error) -> bool {
338        matches!(
339            e,
340            tungstenite::Error::Io(io) if matches!(
341                io.kind(),
342                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
343            )
344        )
345    }
346
347    fn set_read_timeout(ws: &mut Ws, timeout: Option<Duration>) {
348        match ws.get_mut() {
349            MaybeTlsStream::Plain(s) => {
350                let _ = s.set_read_timeout(timeout);
351            }
352            MaybeTlsStream::Rustls(s) => {
353                let _ = s.get_ref().set_read_timeout(timeout);
354            }
355            _ => {}
356        }
357    }
358
359    pub struct NativeTransport {
360        base_url: String,
361        ws_url: String,
362        headers: Vec<(String, String)>,
363        agent: ureq::Agent,
364        pub signed_urls: bool,
365        pub inbound: Arc<InboundBuffer>,
366        socket: Option<Arc<Mutex<Ws>>>,
367        reader: Option<JoinHandle<()>>,
368        reader_stop: Arc<AtomicBool>,
369        round: Arc<RoundChannel>,
370    }
371
372    fn http_err(op: &str, e: impl std::fmt::Display) -> TransportError {
373        TransportError::new("transport.failed", format!("{op}: {e}"))
374    }
375
376    fn derive_ws_url(base_url: &str) -> String {
377        let ws = if let Some(rest) = base_url.strip_prefix("https://") {
378            format!("wss://{rest}")
379        } else if let Some(rest) = base_url.strip_prefix("http://") {
380            format!("ws://{rest}")
381        } else {
382            base_url.to_owned()
383        };
384        let trimmed = ws.trim_end_matches('/');
385        format!("{trimmed}/realtime")
386    }
387
388    impl NativeTransport {
389        pub fn new(base_url: &str, config: &serde_json::Value) -> Result<Self, String> {
390            let mut headers = Vec::new();
391            if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
392                for (k, v) in map {
393                    if let Some(s) = v.as_str() {
394                        headers.push((k.clone(), s.to_owned()));
395                    }
396                }
397            }
398            let ws_url = config
399                .get("wsUrl")
400                .and_then(|v| v.as_str())
401                .map(str::to_owned)
402                .unwrap_or_else(|| derive_ws_url(base_url));
403            Ok(NativeTransport {
404                base_url: base_url.trim_end_matches('/').to_owned(),
405                ws_url,
406                headers,
407                agent: ureq::AgentBuilder::new().build(),
408                signed_urls: false,
409                inbound: Arc::new(InboundBuffer::default()),
410                socket: None,
411                reader: None,
412                reader_stop: Arc::new(AtomicBool::new(false)),
413                round: Arc::new(RoundChannel::default()),
414            })
415        }
416
417        fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
418            let url = format!("{}{}", self.base_url, path);
419            // SSP2 requests carry their own media type (SPEC 1.1); a stock
420            // server answers 415 to anything else.
421            let mut req = self
422                .agent
423                .post(&url)
424                .set("content-type", "application/vnd.syncular.sync.v2");
425            for (k, v) in &self.headers {
426                req = req.set(k, v);
427            }
428            let resp = req.send_bytes(body).map_err(|e| http_err("POST", e))?;
429            read_body(resp)
430        }
431
432        fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
433            let mut req = self.agent.get(url);
434            if with_headers {
435                for (k, v) in &self.headers {
436                    req = req.set(k, v);
437                }
438            }
439            let resp = req.call().map_err(|e| http_err("GET", e))?;
440            read_body(resp)
441        }
442
443        /// Replace the per-request headers (see `HostTransport::set_headers`).
444        pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
445            self.headers = headers;
446        }
447
448        pub fn shutdown(&mut self) {
449            self.reader_stop.store(true, Ordering::SeqCst);
450            self.round.fail_in_flight(TransportError::new(
451                "sync.transport_failed",
452                "realtime disconnected mid-round (§8.7)",
453            ));
454            if let Some(socket) = &self.socket {
455                if let Ok(mut ws) = socket.lock() {
456                    let _ = ws.close(None);
457                    let _ = ws.flush();
458                }
459            }
460            if let Some(handle) = self.reader.take() {
461                let _ = handle.join();
462            }
463            self.socket = None;
464        }
465    }
466
467    fn read_body(resp: ureq::Response) -> Result<Vec<u8>, TransportError> {
468        let mut buf = Vec::new();
469        resp.into_reader()
470            .read_to_end(&mut buf)
471            .map_err(|e| http_err("read", e))?;
472        Ok(buf)
473    }
474
475    impl Transport for NativeTransport {
476        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
477            self.post_sync("/sync", request)
478        }
479
480        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
481            // §8.7 socket round: send the request as a `0x01`-tagged chunk on
482            // the connected socket, block for the reassembled response stream.
483            // No socket connected → the round rides HTTP (same rule as the TS
484            // client: `POST /sync` when the socket is absent).
485            let Some(socket) = self.socket.clone() else {
486                return self.post_sync("/sync", request);
487            };
488            let framed = self.round.begin(request)?;
489            let send = {
490                let mut ws = socket
491                    .lock()
492                    .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
493                ws.send(Message::Binary(framed))
494                    .map_err(|e| http_err("ws round send", &e))
495                    .and_then(|()| ws.flush().map_err(|e| http_err("ws round flush", &e)))
496            };
497            if let Err(e) = send {
498                self.round.fail_in_flight(e);
499            }
500            self.round.wait()
501        }
502
503        fn download_segment(
504            &mut self,
505            request: &SegmentRequest,
506        ) -> Result<Vec<u8>, TransportError> {
507            let id = request
508                .segment_id
509                .strip_prefix("sha256:")
510                .unwrap_or(&request.segment_id);
511            let url = format!("{}/segments/{}", self.base_url, id);
512            self.get_bytes(&url, true)
513        }
514
515        fn supports_url_fetch(&self) -> bool {
516            self.signed_urls
517        }
518
519        fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
520            self.get_bytes(url, false)
521        }
522
523        fn blob_upload(
524            &mut self,
525            blob_id: &str,
526            bytes: &[u8],
527            media_type: Option<&str>,
528        ) -> Result<(), TransportError> {
529            let id = blob_id.strip_prefix("sha256:").unwrap_or(blob_id);
530            let url = format!("{}/blobs/{}", self.base_url, id);
531            let mut req = self.agent.put(&url).set(
532                "content-type",
533                media_type.unwrap_or("application/octet-stream"),
534            );
535            for (k, v) in &self.headers {
536                req = req.set(k, v);
537            }
538            req.send_bytes(bytes).map_err(|e| http_err("PUT blob", e))?;
539            Ok(())
540        }
541
542        fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
543            let id = blob_id.strip_prefix("sha256:").unwrap_or(blob_id);
544            let url = format!("{}/blobs/{}", self.base_url, id);
545            let mut req = self.agent.get(&url);
546            for (k, v) in &self.headers {
547                req = req.set(k, v);
548            }
549            let resp = req.call().map_err(|e| {
550                let e = http_err("GET blob", e);
551                if e.code == "transport.failed" {
552                    TransportError::new("blob.not_found", e.message)
553                } else {
554                    e
555                }
556            })?;
557            // 5.9.5 always-issue: a JSON body carries a presigned `url`; an
558            // octet-stream body is inline bytes. (Mirrors the FFI transport.)
559            let is_json = resp
560                .header("content-type")
561                .is_some_and(|ct| ct.contains("application/json"));
562            let body = read_body(resp)?;
563            if is_json {
564                if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
565                    if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
566                        return Ok(BlobDownload::Url {
567                            url: u.to_owned(),
568                            url_expires_at_ms: parsed
569                                .get("urlExpiresAtMs")
570                                .and_then(|v| v.as_i64()),
571                        });
572                    }
573                }
574            }
575            Ok(BlobDownload::Bytes(body))
576        }
577
578        fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
579            // 5.9.5: the URL is the entire grant — no host credentials.
580            self.get_bytes(url, false)
581        }
582
583        fn realtime_connect(&mut self) -> Result<(), TransportError> {
584            if self.socket.is_some() {
585                return Ok(());
586            }
587            // Build via `IntoClientRequest` so tungstenite fills the mandatory
588            // WS handshake headers (a hand-built `http::Request` would omit
589            // them); then layer the configured auth/actor headers on top.
590            use tungstenite::client::IntoClientRequest;
591            let mut request = self
592                .ws_url
593                .as_str()
594                .into_client_request()
595                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
596            {
597                let out = request.headers_mut();
598                for (k, v) in &self.headers {
599                    if let (Ok(name), Ok(value)) = (
600                        tungstenite::http::HeaderName::try_from(k.as_str()),
601                        tungstenite::http::HeaderValue::try_from(v.as_str()),
602                    ) {
603                        out.insert(name, value);
604                    }
605                }
606            }
607            let (mut ws, _resp) = tungstenite::connect(request)
608                .map_err(|e| TransportError::new("transport.failed", format!("ws connect: {e}")))?;
609            set_read_timeout(&mut ws, Some(READ_TIMEOUT));
610            let socket = Arc::new(Mutex::new(ws));
611            self.socket = Some(Arc::clone(&socket));
612            self.reader_stop.store(false, Ordering::SeqCst);
613            let inbound = Arc::clone(&self.inbound);
614            let stop = Arc::clone(&self.reader_stop);
615            let reader_socket = Arc::clone(&socket);
616            let round = Arc::clone(&self.round);
617            self.reader = Some(std::thread::spawn(move || loop {
618                if stop.load(Ordering::SeqCst) {
619                    break;
620                }
621                let msg = {
622                    let mut ws = match reader_socket.lock() {
623                        Ok(ws) => ws,
624                        Err(_) => break,
625                    };
626                    ws.read()
627                };
628                match msg {
629                    Ok(Message::Text(text)) => inbound.push(Inbound::Text(text)),
630                    Ok(Message::Binary(bytes)) => {
631                        // §8.7 tag demux: `0x01` round chunk → round channel;
632                        // `0x00` delta → inbound (tag stripped, a bare SSP2
633                        // response the client applies like a pull, §8.2).
634                        if let Some(delta) = round.route_binary(&bytes) {
635                            inbound.push(Inbound::Binary(delta));
636                        }
637                    }
638                    Err(e) if is_would_block(&e) => continue,
639                    Ok(Message::Close(_)) | Err(_) => {
640                        round.fail_in_flight(TransportError::new(
641                            "sync.transport_failed",
642                            "realtime disconnected mid-round (§8.7)",
643                        ));
644                        break;
645                    }
646                    Ok(_) => {}
647                }
648            }));
649            Ok(())
650        }
651
652        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
653            let Some(socket) = &self.socket else {
654                return Err(TransportError::new(
655                    "transport.failed",
656                    "realtime not connected",
657                ));
658            };
659            let mut ws = socket
660                .lock()
661                .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
662            ws.send(Message::Text(text.to_owned()))
663                .map_err(|e| http_err("ws send", e))?;
664            ws.flush().map_err(|e| http_err("ws flush", e))?;
665            Ok(())
666        }
667
668        fn realtime_close(&mut self) -> Result<(), TransportError> {
669            self.shutdown();
670            Ok(())
671        }
672    }
673}