//! Server-side bridge for SSR and Dioxus server functions. //! //! Normal SSR/server-function reads should extract the inbound OAuth session //! from the current Axum request and call appview helpers directly in-process. //! The `x-jacquard-session` utility below is only an escape hatch for the rare //! server-side outbound XRPC call that cannot be avoided. use axum::extract::FromRequestParts; use dioxus::prelude::{ServerFnError, dioxus_fullstack::FullstackContext}; use jacquard::identity::PublicResolver; use jacquard::oauth::client::OAuthSession; use jacquard_axum::oauth::ExtractOptionalOAuthSession; use jacquard_common::types::ident::AtIdentifier; use polymodel_api::space_polymodel::actor::ProfileViewUnion; use polymodel_api::space_polymodel::actor::get_session::GetSessionOutput; use polymodel_api::space_polymodel::library::FeedView; use super::actor; use super::error::clamp_limit; use super::state::AppState; use super::views::{self, ModelDetail}; use crate::oauth::SqliteAuthStore; /// Maximum time to wait for the Bluesky profile fallback during SSR before /// degrading to an error state. The polymodel-first path is pure SQLite and /// needs no timeout; this only bounds the outbound `public.api.bsky.app` call. const SSR_BSKY_PROFILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); pub type PolymodelOAuthSession = OAuthSession; /// Extract the current request's optional OAuth session and resolve the /// `getSession` appview output without issuing a same-origin XRPC request. pub async fn get_session_output_from_fullstack_context() -> Result { let (context, state) = context_and_state()?; let session = extract_optional_oauth_session(&context, &state).await?; actor::get_session_output(&state, session.as_ref()) .await .map_err(|error| ServerFnError::new(format!("{error:?}"))) } // --------------------------------------------------------------------------- // Per-read "from fullstack context" helpers // // Each helper resolves the viewer from the request's OAuth session and calls // the same in-process `views::`/`actor::` helper that the corresponding // `/xrpc/*` handler calls. Parity is at the helper level: both paths share the // same query/view logic. // --------------------------------------------------------------------------- /// Resolve a thing (with its models) server-side, same path as `getThing`. #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) async fn get_thing_from_fullstack_context( uri: &str, ) -> Result< ( polymodel_api::space_polymodel::library::ThingView, Vec, ), ServerFnError, > { let (context, state) = context_and_state()?; let session = extract_optional_oauth_session(&context, &state).await?; let viewer = views::viewer_did(session.as_ref()).await; let viewer = viewer.as_ref().map(|d| d.borrow()); views::get_thing(&state, uri, viewer) .await .map_err(|error| ServerFnError::new(format!("{error:?}"))) } /// Resolve a model (with its parts and parent thing) server-side, same path as /// `getModel`. #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) async fn get_model_from_fullstack_context( uri: &str, ) -> Result { let (context, state) = context_and_state()?; let session = extract_optional_oauth_session(&context, &state).await?; let viewer = views::viewer_did(session.as_ref()).await; let viewer = viewer.as_ref().map(|d| d.borrow()); views::get_model(&state, uri, viewer) .await .map_err(|error| ServerFnError::new(format!("{error:?}"))) } /// Resolve a feed server-side, same path as `getFeed`. The `algorithm` string /// dispatches exactly as the XRPC handler. #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) async fn get_feed_from_fullstack_context( algorithm: &str, limit: i64, ) -> Result { let (context, state) = context_and_state()?; let session = extract_optional_oauth_session(&context, &state).await?; let viewer = views::viewer_did(session.as_ref()).await; let viewer = viewer.as_ref().map(|d| d.borrow()); let limit = clamp_limit(Some(limit)); let feed = match algorithm { "recent" => views::feed_recent(&state, limit, None, viewer).await, "hot" => views::feed_hot(&state, limit, None, viewer).await, "following" => match viewer { Some(viewer) => views::feed_following(&state, viewer, limit, None).await, None => Ok(views::empty_feed()), }, other => return Err(ServerFnError::new(format!("unknown algorithm: {other}"))), }; feed.map_err(|error| ServerFnError::new(format!("{error:?}"))) } /// Resolve a profile union server-side, same path as `getProfile`. Polymodel /// profile is tried first (pure SQLite); the Bluesky fallback is bounded by /// [`SSR_BSKY_PROFILE_TIMEOUT`] so a slow upstream cannot block first paint. /// On timeout or error the caller receives an `Err`, which the route maps to /// its existing error state. #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) async fn get_profile_union_from_fullstack_context( actor_ident: &AtIdentifier, ) -> Result { let (_context, state) = context_and_state()?; let did = actor::resolve_actor(&state, actor_ident) .await .map_err(|error| ServerFnError::new(format!("{error:?}")))?; if let Some(profile) = views::polymodel_profile_view(&state, &did) .await .map_err(|error| ServerFnError::new(format!("{error:?}")))? { return Ok(ProfileViewUnion::ProfileView(Box::new(profile))); } match tokio::time::timeout( SSR_BSKY_PROFILE_TIMEOUT, actor::fetch_bsky_profile(&state, actor_ident), ) .await { Ok(Ok(detailed)) => Ok(ProfileViewUnion::ProfileViewDetailed(Box::new(detailed))), Ok(Err(error)) => Err(ServerFnError::new(format!( "Profile request failed: {error:?}" ))), Err(_) => Err(ServerFnError::new( "Profile request timed out waiting for Bluesky fallback", )), } } /// Resolve an actor's things server-side, same path as `getAuthorThings`. The /// actor identifier is canonicalized to a DID before querying, exactly as the /// XRPC handler does. #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) async fn get_author_things_from_fullstack_context( actor_ident: &AtIdentifier, limit: i64, cursor: Option<&str>, ) -> Result { let (context, state) = context_and_state()?; let session = extract_optional_oauth_session(&context, &state).await?; let viewer = views::viewer_did(session.as_ref()).await; let viewer = viewer.as_ref().map(|d| d.borrow()); let did = actor::resolve_actor(&state, actor_ident) .await .map_err(|error| ServerFnError::new(format!("{error:?}")))?; views::author_things( &state, did.as_ref(), clamp_limit(Some(limit)), cursor, viewer, ) .await .map_err(|error| ServerFnError::new(format!("{error:?}"))) } /// Search things server-side, same path as `searchThings`. #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) async fn get_search_from_fullstack_context( query: &str, cursor: Option<&str>, limit: i64, ) -> Result { let (context, state) = context_and_state()?; let session = extract_optional_oauth_session(&context, &state).await?; let viewer = views::viewer_did(session.as_ref()).await; let viewer = viewer.as_ref().map(|d| d.borrow()); views::search_things(&state, query, clamp_limit(Some(limit)), cursor, viewer) .await .map_err(|error| ServerFnError::new(format!("{error:?}"))) } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- fn context_and_state() -> Result<(FullstackContext, AppState), ServerFnError> { let context = FullstackContext::current() .ok_or_else(|| ServerFnError::new("missing Dioxus fullstack request context"))?; let state = context .extension::() .ok_or_else(|| ServerFnError::new("missing Polymodel AppState request extension"))?; Ok((context, state)) } pub(crate) async fn extract_optional_oauth_session( context: &FullstackContext, state: &AppState, ) -> Result, ServerFnError> { let mut parts = context.parts_mut().clone(); ExtractOptionalOAuthSession::::from_request_parts( &mut parts, state, ) .await .map(|ExtractOptionalOAuthSession(session)| session) .map_err(|error| ServerFnError::new(error.to_string())) }