Skip to main content

max / quasi

The axum adapter can resolve its state per request Handler<S> = fn(&S, Params) builds S once and reads it for the life of the program, which is right for a Tauri window, an egui frame and a terminal loop: each has one user. A hosted server is the one host with one process and many viewers, and that is the only assumption it breaks. So the adapter builds S per request. Adapter::per_viewer takes a factory handed the request head, running in async context, which is where a session lookup can happen and where a sync handler cannot go. The router, the handler signature and the description layer are untouched, so nothing downstream republishes. A factory that fails is not a signed-out reader: the request never reaches the router and answers with the error's status and no body, since the one thing a per-request renderer is handed is the state that could not be resolved. The envelope is still refused first, so an oversized body costs no session lookup.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 17:20 UTC
Signed with PGP, not checked
Commit: 5604d6d581edd51ca9fb07c8730b6a82ca7456bc
Parent: 98fad51
2 files changed, +275 insertions, -11 deletions
@@ -17,6 +17,8 @@
17 17 //!
18 18 //! - Mounting, as a fallback. See [`Adapter::into_router`].
19 19 //! - Reading the body, with a limit, before anything is parsed.
20 + //! - Resolving the state a request is answered against, for the one host that
21 + //! serves many viewers out of one process. See [`Adapter::per_viewer`].
20 22 //! - The blocking hop. The router is sync per decision 6, so the call happens
21 23 //! on a blocking thread, which is what an axum handler over a blocking store
22 24 //! pays anyway.
@@ -64,16 +66,39 @@
64 66 //! # }
65 67 //! ```
66 68
69 + use std::future::Future;
70 + use std::pin::Pin;
67 71 use std::sync::Arc;
68 72
69 73 use axum::body::{Body, Bytes};
70 74 use axum::extract::Request;
71 75 use axum::response::Response as HttpResponse;
76 + use http::request::Parts;
72 77 use quasi_http::Refusal;
73 78 use quasi_router::{Params, Response, RouteError, Router};
74 79
75 80 pub use quasi_http::{DEFAULT_BODY_LIMIT, Render, htmx};
76 81
82 + /// The state one request is answered against, once it is resolved.
83 + ///
84 + /// A future, because what a viewer is resolved from is a lookup: a session
85 + /// cookie against a store, a token against an introspection endpoint. That is
86 + /// async work, and it happens here rather than in a handler because a handler
87 + /// is sync by decision 6 and this is the one layer that can await.
88 + ///
89 + /// `Err` is the resolution itself failing, not the viewer being anonymous. A
90 + /// signed-out reader is a state the factory builds; a store that would not
91 + /// answer is a [`RouteError`], and the request never reaches the router.
92 + pub type StateFuture<S> = Pin<Box<dyn Future<Output = Result<S, RouteError>> + Send>>;
93 +
94 + /// Builds the state one request is answered against.
95 + ///
96 + /// The returned future is `'static`, so a factory takes what it needs out of
97 + /// the request parts first and the future owns it. That is the discipline
98 + /// rather than a limitation: a future holding a borrow on the request head
99 + /// would be a second lifetime running through every host that mounts this.
100 + pub type StateFactory<S> = dyn Fn(&Parts) -> StateFuture<S> + Send + Sync;
101 +
77 102 /// Builds the renderer for one request.
78 103 ///
79 104 /// It is handed the state, the request's parameters and the answer the router
@@ -97,10 +122,26 @@
97 122 PerRequest(Box<RenderFactory<S, R>>),
98 123 }
99 124
125 + /// Where the state for a request comes from.
126 + ///
127 + /// `Handler<S> = fn(&S, Params)` builds `S` once and reads it for the life of
128 + /// the program, which is correct for the hosts quasi was designed against: a
129 + /// Tauri window, an egui frame and a terminal loop each have one user, so one
130 + /// of everything is the truth. A hosted server is the one host that breaks
131 + /// that assumption, because it has one process and many viewers.
132 + ///
133 + /// So the adapter builds `S` per request, and nothing above it moves: the
134 + /// router, the handler signature and the description layer are untouched. See
135 + /// [`Adapter::per_viewer`].
136 + enum States<S> {
137 + Shared(Arc<S>),
138 + PerRequest(Box<StateFactory<S>>),
139 + }
140 +
100 141 /// The router, the app's state and a renderer, mounted as an axum service.
101 142 pub struct Adapter<S, R> {
102 143 router: Router<S>,
103 - state: Arc<S>,
144 + state: States<S>,
104 145 render: Renderers<S, R>,
105 146 body_limit: usize,
106 147 }
@@ -108,7 +149,7 @@
108 149 /// What a request needs, once, behind one `Arc`.
109 150 struct Context<S, R> {
110 151 router: Router<S>,
111 - state: Arc<S>,
152 + state: States<S>,
112 153 render: Renderers<S, R>,
113 154 body_limit: usize,
114 155 }
@@ -127,7 +168,7 @@
127 168 pub fn new(router: Router<S>, state: Arc<S>, render: Arc<R>) -> Self {
128 169 Self {
129 170 router,
130 - state,
171 + state: States::Shared(state),
131 172 render: Renderers::Shared(render),
132 173 body_limit: DEFAULT_BODY_LIMIT,
133 174 }
@@ -178,12 +219,82 @@
178 219 {
179 220 Self {
180 221 router,
181 - state,
222 + state: States::Shared(state),
182 223 render: Renderers::PerRequest(Box::new(factory)),
183 224 body_limit: DEFAULT_BODY_LIMIT,
184 225 }
185 226 }
186 227
228 + /// Mount a router that resolves both its state and its renderer per
229 + /// request.
230 + ///
231 + /// For the host with many viewers behind one process. The factory is handed
232 + /// the request head and runs in async context, so the session lookup, the
233 + /// viewer and the theme resolved from them are loaded before the router is
234 + /// called, and a handler reads them off `&S` as it reads anything else.
235 + ///
236 + /// This is why the handler signature did not have to grow. A request-scoped
237 + /// `S` is the same ruling the host boundary already made, that a host fact a
238 + /// described screen needs is one the app puts in `S` while it still has a
239 + /// handle to ask. All that changes for a server is how long `S` lives, and
240 + /// the alternatives all cost more: a second parameter cascades to every
241 + /// repo that has written a handler, an async handler kind was refused by
242 + /// decision 6 for the hosts that have no runtime, and a capability protocol
243 + /// was settled against when the host boundary was drawn.
244 + ///
245 + /// Both factories, because a host that resolves a viewer per request is a
246 + /// host whose renderer has something to say about that viewer: their theme,
247 + /// their chrome, whether the page is signed in. A host that genuinely wants
248 + /// one renderer clones a configured template in the render factory, which is
249 + /// an allocation per request against a store round trip it is already
250 + /// paying.
251 + ///
252 + /// ```no_run
253 + /// # use std::sync::Arc;
254 + /// # use quasi_axum::Adapter;
255 + /// # use quasi_router::Router;
256 + /// # struct Store;
257 + /// # impl Store { async fn viewer(&self, _: Option<String>) -> Option<u64> { None } }
258 + /// # struct Viewer { store: Arc<Store>, viewer: Option<u64> }
259 + /// # #[derive(Clone, Default)] struct Html;
260 + /// # impl quasi_axum::Render for Html {
261 + /// # fn screen(&self, _: &quasi_router::Screen) -> String { String::new() }
262 + /// # fn fragment(&self, _: &quasi_router::Node) -> String { String::new() }
263 + /// # }
264 + /// # let store = Arc::new(Store);
265 + /// # let quasi = Router::<Viewer>::new();
266 + /// Adapter::per_viewer(
267 + /// quasi,
268 + /// move |parts| {
269 + /// // Taken out of the head first: the future owns what it needs.
270 + /// let cookie = parts
271 + /// .headers
272 + /// .get(http::header::COOKIE)
273 + /// .and_then(|value| value.to_str().ok())
274 + /// .map(str::to_owned);
275 + /// let store = Arc::clone(&store);
276 + /// Box::pin(async move {
277 + /// let viewer = store.viewer(cookie).await;
278 + /// Ok(Viewer { store, viewer })
279 + /// })
280 + /// },
281 + /// |_viewer, _params, _answer| Html,
282 + /// );
283 + /// ```
284 + #[must_use]
285 + pub fn per_viewer<F, G>(router: Router<S>, state: F, render: G) -> Self
286 + where
287 + F: Fn(&Parts) -> StateFuture<S> + Send + Sync + 'static,
288 + G: Fn(&S, &Params, Option<&Response>) -> R + Send + Sync + 'static,
289 + {
290 + Self {
291 + router,
292 + state: States::PerRequest(Box::new(state)),
293 + render: Renderers::PerRequest(Box::new(render)),
294 + body_limit: DEFAULT_BODY_LIMIT,
295 + }
296 + }
297 +
187 298 /// Read at most this many bytes of a form body.
188 299 #[must_use]
189 300 pub fn body_limit(mut self, bytes: usize) -> Self {
@@ -244,17 +355,26 @@
244 355 // was asked and what was answered.
245 356 let params = incoming.params.clone();
246 357
358 + // After the envelope is checked and before the router is called. An
359 + // oversized body should not cost a session lookup, and a handler must not
360 + // run before the viewer it reads is resolved.
361 + let state = match &context.state {
362 + States::Shared(state) => Arc::clone(state),
363 + States::PerRequest(factory) => match factory(&parts).await {
364 + Ok(state) => Arc::new(state),
365 + Err(error) => return convert(unresolved(&error)),
366 + },
367 + };
368 +
247 369 // Decision 6: the router is sync. Calling it directly would block the
248 370 // executor for however long the store takes.
249 371 let dispatch = {
250 372 let context = Arc::clone(&context);
373 + let state = Arc::clone(&state);
251 374 tokio::task::spawn_blocking(move || {
252 - context.router.handle(
253 - &context.state,
254 - incoming.method,
255 - &incoming.path,
256 - incoming.params,
257 - )
375 + context
376 + .router
377 + .handle(&state, incoming.method, &incoming.path, incoming.params)
258 378 })
259 379 .await
260 380 };
@@ -270,12 +390,25 @@
270 390 // Built from the answer rather than before it, so the factory can
271 391 // fill the regions this screen has and skip the work for the ones
272 392 // it does not.
273 - let render = factory(&context.state, &params, outcome.as_ref().ok());
393 + let render = factory(&state, &params, outcome.as_ref().ok());
274 394 convert(quasi_http::respond(&render, outcome))
275 395 }
276 396 }
277 397 }
278 398
399 + /// A state factory's failure, which has no description and no renderer.
400 + ///
401 + /// Bodyless, for the reason [`quasi_http::refuse`] is: nothing was reached, so
402 + /// there is nothing to say that the status does not already say. A renderer
403 + /// cannot be built either, since the one thing a per-request renderer is handed
404 + /// is the state that could not be resolved.
405 + fn unresolved(error: &RouteError) -> http::Response<Vec<u8>> {
406 + http::Response::builder()
407 + .status(error.class.http_status())
408 + .body(Vec::new())
409 + .expect("a response with no body and no headers is always valid")
410 + }
411 +
279 412 /// An `http` response with a `Vec` body becomes an axum one.
280 413 fn convert(response: http::Response<Vec<u8>>) -> HttpResponse {
281 414 let (parts, body) = response.into_parts();
@@ -384,3 +384,134 @@
384 384 assert!(served.starts_with("<!doctype html>"));
385 385 assert!(served.contains("hx-get=\"/task/1\""));
386 386 }
387 +
388 + /// An app resolved per request rather than at startup: who is asking.
389 + struct Viewer {
390 + who: String,
391 + }
392 +
393 + /// A renderer that reports what the state factory resolved, so a test can tell
394 + /// the two per-request channels apart.
395 + struct Viewed {
396 + seen: String,
397 + }
398 +
399 + impl super::Render for Viewed {
400 + fn screen(&self, screen: &Screen) -> String {
401 + format!("{}|{}", self.seen, screen.title)
402 + }
403 +
404 + fn fragment(&self, node: &Node) -> String {
405 + match node {
406 + Node::Text { text, .. } => format!("{}|{text}", self.seen),
407 + other => format!("{}|{other:?}", self.seen),
408 + }
409 + }
410 + }
411 +
412 + fn whoami(viewer: &Viewer, _params: Params) -> Result<Response, RouteError> {
413 + Ok(Response::fragment("detail", Node::text(viewer.who.clone())))
414 + }
415 +
416 + /// Read the viewer out of a header, the way a server reads a session cookie.
417 + fn viewer_of(parts: &http::request::Parts) -> super::StateFuture<Viewer> {
418 + let who = parts
419 + .headers
420 + .get("x-who")
421 + .and_then(|value| value.to_str().ok())
422 + .unwrap_or("nobody")
423 + .to_owned();
424 + Box::pin(async move { Ok(Viewer { who }) })
425 + }
426 +
427 + fn as_who(uri: &str, who: &str) -> Request<Body> {
428 + Request::builder()
429 + .uri(uri)
430 + .header("x-who", who)
431 + .body(Body::empty())
432 + .unwrap()
433 + }
434 +
435 + #[tokio::test]
436 + async fn the_state_is_built_per_request_and_the_handler_reads_it() {
437 + // The whole of Q1: identity arrives at a handler whose signature did not
438 + // change. Two requests, one route, two viewers.
439 + let router = Router::<Viewer>::new().get("/whoami", whoami);
440 + let service =
441 + super::Adapter::per_viewer(router, viewer_of, |viewer, _params, _answer| Viewed {
442 + seen: viewer.who.clone(),
443 + })
444 + .into_router();
445 +
446 + let first = service
447 + .clone()
448 + .oneshot(as_who("/whoami", "ada"))
449 + .await
450 + .unwrap();
451 + let first = first.into_body().collect().await.unwrap().to_bytes();
452 + // Left of the bar is what the renderer saw, right of it what the handler
453 + // answered. Both halves are the request's own state.
454 + assert_eq!(String::from_utf8_lossy(&first), "ada|ada");
455 +
456 + let second = service.oneshot(as_who("/whoami", "grace")).await.unwrap();
457 + let second = second.into_body().collect().await.unwrap().to_bytes();
458 + assert_eq!(String::from_utf8_lossy(&second), "grace|grace");
459 + }
460 +
461 + #[tokio::test]
462 + async fn a_state_that_cannot_be_resolved_never_reaches_the_router() {
463 + // A store that will not answer is not a signed-out reader. The handler
464 + // panics, so reading the factory's own status back proves it never ran.
465 + fn never(_viewer: &Viewer, _params: Params) -> Result<Response, RouteError> {
466 + panic!("the router was called without a state");
467 + }
468 +
469 + let router = Router::<Viewer>::new().get("/whoami", never);
470 + let service = super::Adapter::per_viewer(
471 + router,
472 + |_parts| Box::pin(async move { Err(RouteError::denied("the session store said no")) }),
473 + |viewer: &Viewer, _params, _answer| Viewed {
474 + seen: viewer.who.clone(),
475 + },
476 + )
477 + .into_router();
478 +
479 + let response = service.oneshot(get("/whoami")).await.unwrap();
480 + assert_eq!(response.status(), StatusCode::FORBIDDEN);
481 + // No renderer could be built for it, so there is no body to build one for.
482 + let body = response.into_body().collect().await.unwrap().to_bytes();
483 + assert!(body.is_empty());
484 + }
485 +
486 + #[tokio::test]
487 + async fn the_envelope_is_refused_before_the_state_is_resolved() {
488 + // An oversized body or a verb the layer does not have should not cost a
489 + // session lookup. The factory panics, so a 405 is the proof it was ordered
490 + // after the refusal.
491 + let router = Router::<Viewer>::new().get("/whoami", whoami);
492 + let service = super::Adapter::per_viewer(
493 + router,
494 + |_parts| panic!("the state was resolved for a request that was refused"),
495 + |viewer: &Viewer, _params, _answer| Viewed {
496 + seen: viewer.who.clone(),
497 + },
498 + )
499 + .into_router();
500 +
501 + let request = Request::builder()
502 + .method("PUT")
503 + .uri("/whoami")
504 + .body(Body::empty())
505 + .unwrap();
506 + let response = service.oneshot(request).await.unwrap();
507 + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
508 + }
509 +
510 + #[tokio::test]
511 + async fn a_shared_state_still_serves() {
512 + // The change is additive on this axis too: a host with one viewer keeps
513 + // the constructor it had.
514 + let (status, body, _) = send(get("/")).await;
515 + assert_eq!(status, StatusCode::OK);
516 + assert_eq!(body, "screen:Home");
517 + }