···4242tokio = { workspace = true, default-features = false, features = ["sync"] }
43434444# Streaming support (optional)
4545-n0-future = { version = "0.1", optional = true }
4545+n0-future = { workspace = true, optional = true }
4646futures = { version = "0.3", optional = true }
4747tokio-tungstenite-wasm = { version = "0.4", optional = true }
4848genawaiter = { version = "0.99.1", features = ["futures03"] }
+44-9
crates/jacquard-common/src/stream.rs
···4949pub type BoxError = Box<dyn Error + Send + Sync + 'static>;
50505151/// Error type for streaming operations
5252-#[derive(Debug)]
5252+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
5353pub struct StreamError {
5454 kind: StreamErrorKind,
5555+ #[source]
5556 source: Option<BoxError>,
5657}
5758···156157 }
157158}
158159159159-impl Error for StreamError {
160160- fn source(&self) -> Option<&(dyn Error + 'static)> {
161161- self.source
162162- .as_ref()
163163- .map(|e| e.as_ref() as &(dyn Error + 'static))
164164- }
165165-}
166166-167160use bytes::Bytes;
168161use n0_future::stream::Boxed;
169162···203196 /// Convert into the inner boxed stream
204197 pub fn into_inner(self) -> Boxed<Result<Bytes, StreamError>> {
205198 self.inner
199199+ }
200200+201201+ /// Split this stream into two streams that both receive all chunks
202202+ ///
203203+ /// Chunks are cloned (cheaply via Bytes rc). Spawns a forwarder task.
204204+ /// Both returned streams will receive all chunks from the original stream.
205205+ /// The forwarder continues as long as at least one stream is alive.
206206+ /// If the underlying stream errors, both teed streams will end.
207207+ pub fn tee(self) -> (ByteStream, ByteStream) {
208208+ use futures::channel::mpsc;
209209+ use n0_future::StreamExt as _;
210210+211211+ let (tx1, rx1) = mpsc::unbounded();
212212+ let (tx2, rx2) = mpsc::unbounded();
213213+214214+ n0_future::task::spawn(async move {
215215+ let mut stream = self.inner;
216216+ while let Some(result) = stream.next().await {
217217+ match result {
218218+ Ok(chunk) => {
219219+ // Clone chunk (cheap - Bytes is rc'd)
220220+ let chunk2 = chunk.clone();
221221+222222+ // Send to both channels, continue if at least one succeeds
223223+ let send1 = tx1.unbounded_send(Ok(chunk));
224224+ let send2 = tx2.unbounded_send(Ok(chunk2));
225225+226226+ // Only stop if both channels are closed
227227+ if send1.is_err() && send2.is_err() {
228228+ break;
229229+ }
230230+ }
231231+ Err(_e) => {
232232+ // Underlying stream errored, stop forwarding.
233233+ // Both channels will close, ending both streams.
234234+ break;
235235+ }
236236+ }
237237+ }
238238+ });
239239+240240+ (ByteStream::new(rx1), ByteStream::new(rx2))
206241 }
207242}
208243
···15151616use ipld_core::ipld::Ipld;
1717#[cfg(feature = "streaming")]
1818-pub use streaming::StreamingResponse;
1818+pub use streaming::{
1919+ StreamingResponse, XrpcProcedureSend, XrpcProcedureStream, XrpcResponseStream, XrpcStreamResp,
2020+};
19212022#[cfg(feature = "websocket")]
2123pub mod subscription;
···4446use crate::{CowStr, error::XrpcResult};
4547use crate::{IntoStatic, error::DecodeError};
4648#[cfg(feature = "streaming")]
4747-use crate::{
4848- StreamError,
4949- xrpc::streaming::{XrpcProcedureSend, XrpcProcedureStream, XrpcResponseStream, XrpcStreamResp},
5050-};
4949+use crate::StreamError;
5150use crate::{error::TransportError, types::value::RawData};
52515352/// Error type for encoding XRPC requests
···272271#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
273272pub trait XrpcClient: HttpClient {
274273 /// Get the base URI for the client.
275275- fn base_uri(&self) -> Url;
274274+ fn base_uri(&self) -> impl Future<Output = Url>;
276275277276 /// Get the call options for the client.
278277 fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
···316315 where
317316 R: XrpcRequest + Send + Sync,
318317 <R as XrpcRequest>::Response: Send + Sync;
318318+319319+}
320320+321321+/// Stateful XRPC streaming client trait
322322+#[cfg(feature = "streaming")]
323323+pub trait XrpcStreamingClient: XrpcClient + HttpClientExt {
324324+ /// Send an XRPC request and stream the response
325325+ #[cfg(not(target_arch = "wasm32"))]
326326+ fn download<R>(
327327+ &self,
328328+ request: R,
329329+ ) -> impl Future<Output = Result<StreamingResponse, StreamError>> + Send
330330+ where
331331+ R: XrpcRequest + Send + Sync,
332332+ <R as XrpcRequest>::Response: Send + Sync,
333333+ Self: Sync;
334334+335335+ /// Send an XRPC request and stream the response
336336+ #[cfg(target_arch = "wasm32")]
337337+ fn download<R>(
338338+ &self,
339339+ request: R,
340340+ ) -> impl Future<Output = Result<StreamingResponse, StreamError>>
341341+ where
342342+ R: XrpcRequest + Send + Sync,
343343+ <R as XrpcRequest>::Response: Send + Sync;
344344+345345+ /// Stream an XRPC procedure call and its response
346346+ #[cfg(not(target_arch = "wasm32"))]
347347+ fn stream<S>(
348348+ &self,
349349+ stream: XrpcProcedureSend<S::Frame<'static>>,
350350+ ) -> impl Future<Output = Result<XrpcResponseStream<<<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>>, StreamError>>
351351+ where
352352+ S: XrpcProcedureStream + 'static,
353353+ <<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>: XrpcStreamResp,
354354+ Self: Sync;
355355+356356+ /// Stream an XRPC procedure call and its response
357357+ #[cfg(target_arch = "wasm32")]
358358+ fn stream<S>(
359359+ &self,
360360+ stream: XrpcProcedureSend<S::Frame<'static>>,
361361+ ) -> impl Future<Output = Result<XrpcResponseStream<<<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>>, StreamError>>
362362+ where
363363+ S: XrpcProcedureStream + 'static,
364364+ <<S as XrpcProcedureStream>::Response as XrpcStreamResp>::Frame<'static>: XrpcStreamResp;
319365}
320366321367/// Stateless XRPC call builder.
···947993 /// Stream an XRPC procedure call and its response
948994 ///
949995 /// Useful for streaming upload of large payloads, or for "pipe-through" operations
950950- /// where you processing a large payload.
996996+ /// where you are processing a large payload.
951997 pub async fn stream<S>(
952998 self,
953999 stream: XrpcProcedureSend<S::Frame<'static>>,
+1-1
crates/jacquard-common/src/xrpc/streaming.rs
···208208 }
209209}
210210211211-/// XRPC streaming response
211211+/// HTTP streaming response
212212///
213213/// Similar to `Response<R>` but holds a streaming body instead of a buffer.
214214pub struct StreamingResponse {
+3-3
crates/jacquard-common/src/xrpc/subscription.rs
···472472#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
473473pub trait SubscriptionClient: WebSocketClient {
474474 /// Get the base URI for the client.
475475- fn base_uri(&self) -> Url;
475475+ fn base_uri(&self) -> impl Future<Output = Url>;
476476477477 /// Get the subscription options for the client.
478478 fn subscription_opts(&self) -> impl Future<Output = SubscriptionOptions<'_>> {
···570570}
571571572572impl<W: WebSocketClient> SubscriptionClient for BasicSubscriptionClient<W> {
573573- fn base_uri(&self) -> Url {
573573+ async fn base_uri(&self) -> Url {
574574 self.base_uri.clone()
575575 }
576576···613613 Sub: XrpcSubscription + Send + Sync,
614614 Self: Sync,
615615 {
616616- let base = self.base_uri();
616616+ let base = self.base_uri().await;
617617 self.subscription(base)
618618 .with_options(opts)
619619 .subscribe(params)
···433433 T: HttpClient + XrpcExt + Send + Sync + 'static,
434434 W: Send + Sync,
435435{
436436- fn base_uri(&self) -> Url {
437437- // base_uri is a synchronous trait method; avoid `.await` here.
438438- // Under Tokio, use `block_in_place` to make a blocking RwLock read safe.
439439- #[cfg(not(target_arch = "wasm32"))]
440440- if tokio::runtime::Handle::try_current().is_ok() {
441441- tokio::task::block_in_place(|| {
442442- self.endpoint.blocking_read().clone().unwrap_or(
443443- Url::parse("https://public.bsky.app")
444444- .expect("public appview should be valid url"),
445445- )
446446- })
447447- } else {
448448- self.endpoint.blocking_read().clone().unwrap_or(
449449- Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
450450- )
451451- }
452452-453453- #[cfg(target_arch = "wasm32")]
454454- {
455455- self.endpoint.blocking_read().clone().unwrap_or(
456456- Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
457457- )
458458- }
436436+ async fn base_uri(&self) -> Url {
437437+ self.endpoint.read().await.clone().unwrap_or(
438438+ Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
439439+ )
459440 }
460441461442 async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>>
···476457 R: XrpcRequest + Send + Sync,
477458 <R as XrpcRequest>::Response: Send + Sync,
478459 {
479479- let base_uri = self.base_uri();
460460+ let base_uri = self.base_uri().await;
480461 let auth = self.access_token().await;
481462 opts.auth = auth;
482463 let resp = self
···512493 }
513494}
514495496496+#[cfg(feature = "streaming")]
497497+impl<S, T, W> jacquard_common::http_client::HttpClientExt for CredentialSession<S, T, W>
498498+where
499499+ S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
500500+ T: HttpClient + XrpcExt + jacquard_common::http_client::HttpClientExt + Send + Sync + 'static,
501501+ W: Send + Sync,
502502+{
503503+ async fn send_http_streaming(
504504+ &self,
505505+ request: http::Request<Vec<u8>>,
506506+ ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> {
507507+ self.client.send_http_streaming(request).await
508508+ }
509509+510510+ async fn send_http_bidirectional<Str>(
511511+ &self,
512512+ parts: http::request::Parts,
513513+ body: Str,
514514+ ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error>
515515+ where
516516+ Str: n0_future::Stream<Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>>
517517+ + Send
518518+ + 'static,
519519+ {
520520+ self.client.send_http_bidirectional(parts, body).await
521521+ }
522522+}
523523+524524+#[cfg(feature = "streaming")]
525525+impl<S, T, W> jacquard_common::xrpc::XrpcStreamingClient for CredentialSession<S, T, W>
526526+where
527527+ S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
528528+ T: HttpClient + XrpcExt + jacquard_common::http_client::HttpClientExt + Send + Sync + 'static,
529529+ W: Send + Sync,
530530+{
531531+ async fn download<R>(
532532+ &self,
533533+ request: R,
534534+ ) -> core::result::Result<jacquard_common::xrpc::StreamingResponse, jacquard_common::StreamError>
535535+ where
536536+ R: XrpcRequest + Send + Sync,
537537+ <R as XrpcRequest>::Response: Send + Sync,
538538+ {
539539+ use jacquard_common::{StreamError, xrpc::build_http_request};
540540+541541+ let base_uri = <Self as XrpcClient>::base_uri(self).await;
542542+ let mut opts = self.options.read().await.clone();
543543+ opts.auth = self.access_token().await;
544544+545545+ let http_request = build_http_request(&base_uri, &request, &opts)
546546+ .map_err(|e| StreamError::protocol(e.to_string()))?;
547547+548548+ let response = self
549549+ .client
550550+ .send_http_streaming(http_request.clone())
551551+ .await
552552+ .map_err(StreamError::transport)?;
553553+554554+ let (parts, body) = response.into_parts();
555555+ let status = parts.status;
556556+557557+ // Check if expired based on status code
558558+ if status == http::StatusCode::UNAUTHORIZED || status == http::StatusCode::BAD_REQUEST {
559559+ // Try to refresh
560560+ let auth = self.refresh().await.map_err(StreamError::transport)?;
561561+ opts.auth = Some(auth);
562562+563563+ let http_request = build_http_request(&base_uri, &request, &opts)
564564+ .map_err(|e| StreamError::protocol(e.to_string()))?;
565565+566566+ let response = self
567567+ .client
568568+ .send_http_streaming(http_request)
569569+ .await
570570+ .map_err(StreamError::transport)?;
571571+ let (parts, body) = response.into_parts();
572572+ Ok(jacquard_common::xrpc::StreamingResponse::new(parts, body))
573573+ } else {
574574+ Ok(jacquard_common::xrpc::StreamingResponse::new(parts, body))
575575+ }
576576+ }
577577+578578+ async fn stream<Str>(
579579+ &self,
580580+ stream: jacquard_common::xrpc::streaming::XrpcProcedureSend<Str::Frame<'static>>,
581581+ ) -> core::result::Result<
582582+ jacquard_common::xrpc::streaming::XrpcResponseStream<
583583+ <<Str as jacquard_common::xrpc::streaming::XrpcProcedureStream>::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>,
584584+ >,
585585+ jacquard_common::StreamError,
586586+ >
587587+ where
588588+ Str: jacquard_common::xrpc::streaming::XrpcProcedureStream + 'static,
589589+ <<Str as jacquard_common::xrpc::streaming::XrpcProcedureStream>::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>: jacquard_common::xrpc::streaming::XrpcStreamResp,
590590+ {
591591+ use jacquard_common::StreamError;
592592+ use n0_future::{StreamExt, TryStreamExt};
593593+594594+ let base_uri = self.base_uri().await;
595595+ let mut opts = self.options.read().await.clone();
596596+ opts.auth = self.access_token().await;
597597+598598+ let mut url = base_uri;
599599+ let mut path = url.path().trim_end_matches('/').to_owned();
600600+ path.push_str("/xrpc/");
601601+ path.push_str(<Str::Request as jacquard_common::xrpc::XrpcRequest>::NSID);
602602+ url.set_path(&path);
603603+604604+ let mut builder = http::Request::post(url.to_string());
605605+606606+ if let Some(token) = &opts.auth {
607607+ use jacquard_common::AuthorizationToken;
608608+ let hv = match token {
609609+ AuthorizationToken::Bearer(t) => {
610610+ http::HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
611611+ }
612612+ AuthorizationToken::Dpop(t) => {
613613+ http::HeaderValue::from_str(&format!("DPoP {}", t.as_ref()))
614614+ }
615615+ }
616616+ .map_err(|e| StreamError::protocol(format!("Invalid authorization token: {}", e)))?;
617617+ builder = builder.header(http::header::AUTHORIZATION, hv);
618618+ }
619619+620620+ if let Some(proxy) = &opts.atproto_proxy {
621621+ builder = builder.header("atproto-proxy", proxy.as_ref());
622622+ }
623623+ if let Some(labelers) = &opts.atproto_accept_labelers {
624624+ if !labelers.is_empty() {
625625+ let joined = labelers
626626+ .iter()
627627+ .map(|s| s.as_ref())
628628+ .collect::<Vec<_>>()
629629+ .join(", ");
630630+ builder = builder.header("atproto-accept-labelers", joined);
631631+ }
632632+ }
633633+ for (name, value) in &opts.extra_headers {
634634+ builder = builder.header(name, value);
635635+ }
636636+637637+ let (parts, _) = builder
638638+ .body(())
639639+ .map_err(|e| StreamError::protocol(e.to_string()))?
640640+ .into_parts();
641641+642642+ let body_stream =
643643+ jacquard_common::stream::ByteStream::new(stream.0.map_ok(|f| f.buffer).boxed());
644644+645645+ let response = self
646646+ .client
647647+ .send_http_bidirectional(parts.clone(), body_stream.into_inner())
648648+ .await
649649+ .map_err(StreamError::transport)?;
650650+651651+ let (resp_parts, resp_body) = response.into_parts();
652652+ let status = resp_parts.status;
653653+654654+ // Check if expired
655655+ if status == http::StatusCode::UNAUTHORIZED || status == http::StatusCode::BAD_REQUEST {
656656+ // Try to refresh
657657+ let auth = self.refresh().await.map_err(StreamError::transport)?;
658658+ opts.auth = Some(auth);
659659+660660+ // Rebuild request with new auth
661661+ let mut builder = http::Request::post(url.to_string());
662662+ if let Some(token) = &opts.auth {
663663+ use jacquard_common::AuthorizationToken;
664664+ let hv = match token {
665665+ AuthorizationToken::Bearer(t) => {
666666+ http::HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
667667+ }
668668+ AuthorizationToken::Dpop(t) => {
669669+ http::HeaderValue::from_str(&format!("DPoP {}", t.as_ref()))
670670+ }
671671+ }
672672+ .map_err(|e| StreamError::protocol(format!("Invalid authorization token: {}", e)))?;
673673+ builder = builder.header(http::header::AUTHORIZATION, hv);
674674+ }
675675+ if let Some(proxy) = &opts.atproto_proxy {
676676+ builder = builder.header("atproto-proxy", proxy.as_ref());
677677+ }
678678+ if let Some(labelers) = &opts.atproto_accept_labelers {
679679+ if !labelers.is_empty() {
680680+ let joined = labelers
681681+ .iter()
682682+ .map(|s| s.as_ref())
683683+ .collect::<Vec<_>>()
684684+ .join(", ");
685685+ builder = builder.header("atproto-accept-labelers", joined);
686686+ }
687687+ }
688688+ for (name, value) in &opts.extra_headers {
689689+ builder = builder.header(name, value);
690690+ }
691691+692692+ let (parts, _) = builder
693693+ .body(())
694694+ .map_err(|e| StreamError::protocol(e.to_string()))?
695695+ .into_parts();
696696+697697+ // Can't retry with the same stream - it's been consumed
698698+ // This is a limitation of streaming upload with auth refresh
699699+ return Err(StreamError::protocol("Authentication failed on streaming upload and stream cannot be retried".to_string()));
700700+ }
701701+702702+ Ok(jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts(
703703+ resp_parts, resp_body,
704704+ ))
705705+ }
706706+}
707707+515708impl<S, T, W> IdentityResolver for CredentialSession<S, T, W>
516709where
517710 S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
···596789 AuthorizationToken::Bearer(t) => format!("Bearer {}", t.as_ref()),
597790 AuthorizationToken::Dpop(t) => format!("DPoP {}", t.as_ref()),
598791 };
599599- opts.headers.push((
600600- CowStr::from("Authorization"),
601601- CowStr::from(auth_value),
602602- ));
792792+ opts.headers
793793+ .push((CowStr::from("Authorization"), CowStr::from(auth_value)));
603794 }
604795 opts
605796 }
+3
crates/jacquard/src/lib.rs
···219219220220pub mod client;
221221222222+#[cfg(feature = "streaming")]
223223+pub mod streaming;
224224+222225pub use common::*;
223226#[cfg(feature = "api")]
224227pub use jacquard_api as api;
+3
crates/jacquard/src/streaming.rs
···11+pub mod blob;
22+pub mod repo;
33+pub mod video;