···6969/// Fetch `app.bsky.actor.getProfile` from `https://public.api.bsky.app` via the
7070/// unauthenticated jacquard client. This is the only network path in the read
7171/// endpoints; tests exercise union-variant construction without it.
7272-async fn fetch_bsky_profile(
7272+pub(crate) async fn fetch_bsky_profile(
7373 state: &AppState,
7474 ident: &AtIdentifier,
7575) -> AppResult<ProfileViewDetailed> {
+168-5
src/appview/ssr.rs
···1212use jacquard::oauth::client::OAuthSession;
1313use jacquard_axum::oauth::ExtractOptionalOAuthSession;
1414use jacquard_common::session::SessionKey;
1515+use jacquard_common::types::ident::AtIdentifier;
1516use jacquard_common::xrpc::CallOptions;
1717+use polymodel_api::space_polymodel::actor::ProfileViewUnion;
1618use polymodel_api::space_polymodel::actor::get_session::GetSessionOutput;
1919+use polymodel_api::space_polymodel::library::FeedView;
17201821use super::actor;
2222+use super::error::clamp_limit;
1923use super::state::AppState;
2424+use super::views::{self, ModelDetail};
2025use crate::oauth::SqliteAuthStore;
21262727+/// Maximum time to wait for the Bluesky profile fallback during SSR before
2828+/// degrading to an error state. The polymodel-first path is pure SQLite and
2929+/// needs no timeout; this only bounds the outbound `public.api.bsky.app` call.
3030+const SSR_BSKY_PROFILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
3131+2232pub type PolymodelOAuthSession = OAuthSession<PublicResolver, SqliteAuthStore>;
23332434/// Extract the current request's optional OAuth session and resolve the
2535/// `getSession` appview output without issuing a same-origin XRPC request.
2636pub async fn get_session_output_from_fullstack_context() -> Result<GetSessionOutput, ServerFnError>
2737{
3838+ let (context, state) = context_and_state()?;
3939+ let session = extract_optional_oauth_session(&context, &state).await?;
4040+ actor::get_session_output(&state, session.as_ref())
4141+ .await
4242+ .map_err(|error| ServerFnError::new(format!("{error:?}")))
4343+}
4444+4545+// ---------------------------------------------------------------------------
4646+// Per-read "from fullstack context" helpers
4747+//
4848+// Each helper resolves the viewer from the request's OAuth session and calls
4949+// the same in-process `views::`/`actor::` helper that the corresponding
5050+// `/xrpc/*` handler calls. Parity is at the helper level: both paths share the
5151+// same query/view logic.
5252+// ---------------------------------------------------------------------------
5353+5454+/// Resolve a thing (with its models) server-side, same path as `getThing`.
5555+#[cfg_attr(not(feature = "server"), allow(dead_code))]
5656+pub(crate) async fn get_thing_from_fullstack_context(
5757+ uri: &str,
5858+) -> Result<
5959+ (
6060+ polymodel_api::space_polymodel::library::ThingView,
6161+ Vec<polymodel_api::space_polymodel::library::ModelView>,
6262+ ),
6363+ ServerFnError,
6464+> {
6565+ let (context, state) = context_and_state()?;
6666+ let session = extract_optional_oauth_session(&context, &state).await?;
6767+ let viewer = views::viewer_did(session.as_ref()).await;
6868+ let viewer = viewer.as_ref().map(|d| d.borrow());
6969+ views::get_thing(&state, uri, viewer)
7070+ .await
7171+ .map_err(|error| ServerFnError::new(format!("{error:?}")))
7272+}
7373+7474+/// Resolve a model (with its parts and parent thing) server-side, same path as
7575+/// `getModel`.
7676+#[cfg_attr(not(feature = "server"), allow(dead_code))]
7777+pub(crate) async fn get_model_from_fullstack_context(
7878+ uri: &str,
7979+) -> Result<ModelDetail, ServerFnError> {
8080+ let (context, state) = context_and_state()?;
8181+ let session = extract_optional_oauth_session(&context, &state).await?;
8282+ let viewer = views::viewer_did(session.as_ref()).await;
8383+ let viewer = viewer.as_ref().map(|d| d.borrow());
8484+ views::get_model(&state, uri, viewer)
8585+ .await
8686+ .map_err(|error| ServerFnError::new(format!("{error:?}")))
8787+}
8888+8989+/// Resolve a feed server-side, same path as `getFeed`. The `algorithm` string
9090+/// dispatches exactly as the XRPC handler: `"recent"` or `"hot"`.
9191+#[cfg_attr(not(feature = "server"), allow(dead_code))]
9292+pub(crate) async fn get_feed_from_fullstack_context(
9393+ algorithm: &str,
9494+ limit: i64,
9595+) -> Result<FeedView, ServerFnError> {
9696+ let (context, state) = context_and_state()?;
9797+ let session = extract_optional_oauth_session(&context, &state).await?;
9898+ let viewer = views::viewer_did(session.as_ref()).await;
9999+ let viewer = viewer.as_ref().map(|d| d.borrow());
100100+ let limit = clamp_limit(Some(limit));
101101+ let feed = match algorithm {
102102+ "recent" => views::feed_recent(&state, limit, None, viewer).await,
103103+ "hot" => views::feed_hot(&state, limit, None, viewer).await,
104104+ other => return Err(ServerFnError::new(format!("unknown algorithm: {other}"))),
105105+ };
106106+ feed.map_err(|error| ServerFnError::new(format!("{error:?}")))
107107+}
108108+109109+/// Resolve a profile union server-side, same path as `getProfile`. Polymodel
110110+/// profile is tried first (pure SQLite); the Bluesky fallback is bounded by
111111+/// [`SSR_BSKY_PROFILE_TIMEOUT`] so a slow upstream cannot block first paint.
112112+/// On timeout or error the caller receives an `Err`, which the route maps to
113113+/// its existing error state.
114114+#[cfg_attr(not(feature = "server"), allow(dead_code))]
115115+pub(crate) async fn get_profile_union_from_fullstack_context(
116116+ actor_ident: &AtIdentifier,
117117+) -> Result<ProfileViewUnion, ServerFnError> {
118118+ let (_context, state) = context_and_state()?;
119119+ let did = actor::resolve_actor(&state, actor_ident)
120120+ .await
121121+ .map_err(|error| ServerFnError::new(format!("{error:?}")))?;
122122+123123+ if let Some(profile) = views::polymodel_profile_view(&state, &did)
124124+ .await
125125+ .map_err(|error| ServerFnError::new(format!("{error:?}")))?
126126+ {
127127+ return Ok(ProfileViewUnion::ProfileView(Box::new(profile)));
128128+ }
129129+130130+ match tokio::time::timeout(
131131+ SSR_BSKY_PROFILE_TIMEOUT,
132132+ actor::fetch_bsky_profile(&state, actor_ident),
133133+ )
134134+ .await
135135+ {
136136+ Ok(Ok(detailed)) => Ok(ProfileViewUnion::ProfileViewDetailed(Box::new(detailed))),
137137+ Ok(Err(error)) => Err(ServerFnError::new(format!(
138138+ "Profile request failed: {error:?}"
139139+ ))),
140140+ Err(_) => Err(ServerFnError::new(
141141+ "Profile request timed out waiting for Bluesky fallback",
142142+ )),
143143+ }
144144+}
145145+146146+/// Resolve an actor's things server-side, same path as `getAuthorThings`. The
147147+/// actor identifier is canonicalized to a DID before querying, exactly as the
148148+/// XRPC handler does.
149149+#[cfg_attr(not(feature = "server"), allow(dead_code))]
150150+pub(crate) async fn get_author_things_from_fullstack_context(
151151+ actor_ident: &AtIdentifier,
152152+ limit: i64,
153153+ cursor: Option<&str>,
154154+) -> Result<FeedView, ServerFnError> {
155155+ let (context, state) = context_and_state()?;
156156+ let session = extract_optional_oauth_session(&context, &state).await?;
157157+ let viewer = views::viewer_did(session.as_ref()).await;
158158+ let viewer = viewer.as_ref().map(|d| d.borrow());
159159+ let did = actor::resolve_actor(&state, actor_ident)
160160+ .await
161161+ .map_err(|error| ServerFnError::new(format!("{error:?}")))?;
162162+ views::author_things(
163163+ &state,
164164+ did.as_ref(),
165165+ clamp_limit(Some(limit)),
166166+ cursor,
167167+ viewer,
168168+ )
169169+ .await
170170+ .map_err(|error| ServerFnError::new(format!("{error:?}")))
171171+}
172172+173173+/// Search things server-side, same path as `searchThings`.
174174+#[cfg_attr(not(feature = "server"), allow(dead_code))]
175175+pub(crate) async fn get_search_from_fullstack_context(
176176+ query: &str,
177177+ cursor: Option<&str>,
178178+ limit: i64,
179179+) -> Result<FeedView, ServerFnError> {
180180+ let (context, state) = context_and_state()?;
181181+ let session = extract_optional_oauth_session(&context, &state).await?;
182182+ let viewer = views::viewer_did(session.as_ref()).await;
183183+ let viewer = viewer.as_ref().map(|d| d.borrow());
184184+ views::search_things(&state, query, clamp_limit(Some(limit)), cursor, viewer)
185185+ .await
186186+ .map_err(|error| ServerFnError::new(format!("{error:?}")))
187187+}
188188+189189+// ---------------------------------------------------------------------------
190190+// Internals
191191+// ---------------------------------------------------------------------------
192192+193193+fn context_and_state() -> Result<(FullstackContext, AppState), ServerFnError> {
28194 let context = FullstackContext::current()
29195 .ok_or_else(|| ServerFnError::new("missing Dioxus fullstack request context"))?;
30196 let state = context
31197 .extension::<AppState>()
32198 .ok_or_else(|| ServerFnError::new("missing Polymodel AppState request extension"))?;
3333- let session = extract_optional_oauth_session(&context, &state).await?;
3434- actor::get_session_output(&state, session.as_ref())
3535- .await
3636- .map_err(|error| ServerFnError::new(format!("{error:?}")))
199199+ Ok((context, state))
37200}
382013939-async fn extract_optional_oauth_session(
202202+pub(crate) async fn extract_optional_oauth_session(
40203 context: &FullstackContext,
41204 state: &AppState,
42205) -> Result<Option<PolymodelOAuthSession>, ServerFnError> {
+19-27
src/browse.rs
···11use dioxus::prelude::ServerFnError;
22use dioxus::prelude::*;
33-use jacquard_common::xrpc::XrpcClient;
43use polymodel_api::space_polymodel::library;
5465use crate::Route;
77-use crate::client::PolymodelClient;
86use crate::examples::{HERO_SAMPLE_HANDLES, first_hero_sample_handle};
97use crate::profile::profile_href;
108use crate::session::SessionIdentity;
···120118#[allow(clippy::useless_format)]
121119#[component]
122120pub(crate) fn Browse() -> Element {
123123- let client = use_context::<PolymodelClient>();
124121 let selected_algorithm = use_signal(|| FeedAlgorithm::Hot);
125122126126- let mut feed = use_resource(move || {
127127- let client = client.clone();
128128- let algorithm = *selected_algorithm.read();
129129- async move {
130130- let request = library::get_feed::GetFeed::new()
131131- .algorithm(Some(algorithm.as_str().into()))
132132- .limit(24)
133133- .build();
134134- let response = client
135135- .send(request)
136136- .await
137137- .map_err(|error| format!("Feed request failed: {error}"))?;
138138- let output = response
139139- .into_output()
140140- .map_err(|error| format!("Feed decode failed: {error}"))?;
141141- Ok(output
142142- .value
143143- .items
144144- .into_iter()
145145- .map(|item| item.thing)
146146- .collect::<Vec<_>>())
147147- }
148148- });
123123+ let mut feed = use_server_future(move || {
124124+ let algorithm = selected_algorithm.read().as_str().to_owned();
125125+ feed_seed(algorithm, 24)
126126+ })?;
149127150128 let feed_result = feed.read();
151151- let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref());
129129+ let mapped = feed_result.as_ref().map(|r| match r {
130130+ Ok(things) => Ok(things.clone()),
131131+ Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()),
132132+ Err(other) => Err(other.to_string()),
133133+ });
134134+ let feed_state = browse_feed_state(feed.pending(), mapped.as_ref());
152135153136 // Show the logged-out hero band only to definitively anonymous visitors who
154137 // haven't dismissed it. Both inputs resolve server-side during SSR (session
···301284 let parts = context.parts_mut().clone();
302285 let jar = CookieJar::from_headers(&parts.headers);
303286 Ok(jar.get(HERO_DISMISS_COOKIE).is_some())
287287+}
288288+289289+#[server]
290290+async fn feed_seed(
291291+ algorithm: String,
292292+ limit: i64,
293293+) -> Result<Vec<library::ThingViewBasic>, ServerFnError> {
294294+ let feed = crate::appview::ssr::get_feed_from_fullstack_context(&algorithm, limit).await?;
295295+ Ok(feed.items.into_iter().map(|item| item.thing).collect())
304296}
305297306298#[cfg(test)]
+26-37
src/profile.rs
···11use dioxus::prelude::*;
22use jacquard_common::deps::smol_str::SmolStr;
33-use jacquard_common::types::ident::AtIdentifier;
43use jacquard_common::types::string::{Did, Handle, UriValue};
55-use jacquard_common::xrpc::XrpcClient;
66-use polymodel_api::space_polymodel::actor::{ProfileViewUnion, get_profile::GetProfile};
44+use polymodel_api::space_polymodel::actor::ProfileViewUnion;
75use polymodel_api::space_polymodel::library;
88-use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings;
96107use crate::Route;
1111-use crate::client::PolymodelClient;
128use crate::session::SessionIdentity;
139use crate::thing_card::ThingCard;
1410use crate::thing_detail::thing_detail_href;
···4036 Populated(Box<ProfileData>),
4137}
42384343-#[derive(Clone, Debug, PartialEq, Eq)]
3939+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4440struct ProfileData {
4541 header: ProfileHeader,
4642 things: Vec<library::ThingViewBasic>,
···5450 Populated(Vec<library::ThingViewBasic>),
5551}
56525757-#[derive(Clone, Debug, PartialEq, Eq)]
5353+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5854struct ProfileHeader {
5955 avatar: Option<UriValue>,
6056 default_license: Option<SmolStr>,
···109105/// bare `handle` string is branded into a `Handle` here (single conversion site);
110106/// the bsky arm already carries a branded `Handle`. `did` is `Did` on both arms
111107/// and is cloned directly.
108108+#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))]
112109fn profile_header(value: &ProfileViewUnion) -> ProfileHeader {
113110 match value {
114111 ProfileViewUnion::ProfileView(view) => ProfileHeader {
···141138 }
142139}
143140141141+#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))]
144142fn optional_smolstr(value: &Option<impl AsRef<str>>) -> Option<SmolStr> {
145143 value
146144 .as_ref()
···187185 })
188186}
189187190190-async fn load_profile(client: PolymodelClient, actor_str: &str) -> Result<ProfileData, String> {
191191- let actor = AtIdentifier::new_owned(actor_str)
192192- .map_err(|error| format!("Invalid actor: unsupported actor identifier: {error}"))?;
188188+#[server]
189189+async fn profile_seed(actor: String) -> Result<ProfileData, ServerFnError> {
190190+ use jacquard_common::types::ident::AtIdentifier;
193191194194- let profile_response = client
195195- .send(GetProfile::new().actor(actor.clone()).build())
196196- .await
197197- .map_err(|error| format!("Profile request failed: {error}"))?;
198198- let profile_output = profile_response
199199- .into_output()
200200- .map_err(|error| format!("Profile decode failed: {error}"))?;
201201- let header = profile_header(&profile_output.value);
192192+ let actor_id = AtIdentifier::new_owned(&actor).map_err(|e| {
193193+ ServerFnError::new(format!("Invalid actor: unsupported actor identifier: {e}"))
194194+ })?;
195195+196196+ let union = crate::appview::ssr::get_profile_union_from_fullstack_context(&actor_id).await?;
197197+ let header = profile_header(&union);
202198203203- let things_response = client
204204- .send(GetAuthorThings::new().actor(actor).limit(24).build())
205205- .await
206206- .map_err(|error| format!("Author things request failed: {error}"))?;
207207- let things_output = things_response
208208- .into_output()
209209- .map_err(|error| format!("Author things decode failed: {error}"))?;
210210- let things = things_output
211211- .value
212212- .items
213213- .into_iter()
214214- .map(|item| item.thing)
215215- .collect();
199199+ let feed =
200200+ crate::appview::ssr::get_author_things_from_fullstack_context(&actor_id, 24, None).await?;
201201+ let things = feed.items.into_iter().map(|item| item.thing).collect();
216202217203 Ok(ProfileData { header, things })
218204}
···278264#[allow(clippy::useless_format)]
279265#[component]
280266fn ProfileBody(actor: String) -> Element {
281281- let client = use_context::<PolymodelClient>();
282267 let session = use_context::<Signal<SessionIdentity>>();
283268 let active_tab = use_signal(|| ProfileTab::Things);
284269285285- let mut profile = use_resource(move || {
286286- let client = client.clone();
270270+ let mut profile = use_server_future(move || {
287271 let actor = actor.clone();
288288- async move { load_profile(client, &actor).await }
289289- });
272272+ profile_seed(actor)
273273+ })?;
290274291275 let profile_result = profile.read();
292292- let state = profile_state(profile.pending(), profile_result.as_ref());
276276+ let mapped = profile_result.as_ref().map(|r| match r {
277277+ Ok(data) => Ok(data.clone()),
278278+ Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()),
279279+ Err(other) => Err(other.to_string()),
280280+ });
281281+ let state = profile_state(profile.pending(), mapped.as_ref());
293282294283 rsx! {
295284 main { class: "profile-page product-shell",
···122122 let identity = session.read().clone();
123123 let return_to = current_return_to();
124124 let handle_placeholder = use_server_cached(random_sample_handle);
125125- let mut identifier = use_signal(|| String::new());
125125+ let mut identifier = use_signal(String::new);
126126127127 match identity {
128128 // Session still resolving: render nothing rather than flashing a sign-in