Skip to main content

max / quasi

quasi-tauri: state that arrives after the builder Tauri's own case, and goingson is the app that hits it. `AppState::new` takes an `AppHandle` to resolve the data directory, the handle exists only once `Builder::build` runs, and every scheme has to be registered before that. So `Protocol::new` asking for the state up front is a requirement no real Tauri app with a database can meet, and goingson's protocol has sat unmounted behind its feature as a result. `Protocol::pending` hands the builder a protocol now and the setup closure a `Late<S>` to fill in once. A request in the gap answers 503 rather than blocking: the window is not up yet, so there is nobody to keep waiting. `Protocol::new` is unchanged from a caller's side and stays the shape the template uses, where the state is just a file path.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-21 23:19 UTC
Signed with PGP, not checked
Commit: d9a67b8344dd00f8cd2c321b8b4603734d296a7e
Parent: 33d02c9
3 files changed, +130 insertions, -21 deletions
M Cargo.lock +16 -16
@@ -3404,11 +3404,11 @@
3404 3404
3405 3405 [[package]]
3406 3406 name = "quasi"
3407 - version = "0.45.0"
3407 + version = "0.46.0"
3408 3408
3409 3409 [[package]]
3410 3410 name = "quasi-axum"
3411 - version = "0.45.0"
3411 + version = "0.46.0"
3412 3412 dependencies = [
3413 3413 "axum",
3414 3414 "http",
@@ -3422,7 +3422,7 @@
3422 3422
3423 3423 [[package]]
3424 3424 name = "quasi-basics"
3425 - version = "0.45.0"
3425 + version = "0.46.0"
3426 3426 dependencies = [
3427 3427 "makeover-layout",
3428 3428 "quasi-http",
@@ -3432,7 +3432,7 @@
3432 3432
3433 3433 [[package]]
3434 3434 name = "quasi-bench"
3435 - version = "0.45.0"
3435 + version = "0.46.0"
3436 3436 dependencies = [
3437 3437 "dhat",
3438 3438 "makeover",
@@ -3447,7 +3447,7 @@
3447 3447
3448 3448 [[package]]
3449 3449 name = "quasi-http"
3450 - version = "0.45.0"
3450 + version = "0.46.0"
3451 3451 dependencies = [
3452 3452 "form_urlencoded",
3453 3453 "http",
@@ -3456,7 +3456,7 @@
3456 3456
3457 3457 [[package]]
3458 3458 name = "quasi-immediate"
3459 - version = "0.45.0"
3459 + version = "0.46.0"
3460 3460 dependencies = [
3461 3461 "docengine",
3462 3462 "egui",
@@ -3466,7 +3466,7 @@
3466 3466
3467 3467 [[package]]
3468 3468 name = "quasi-notifs"
3469 - version = "0.45.0"
3469 + version = "0.46.0"
3470 3470 dependencies = [
3471 3471 "quasi-router",
3472 3472 "synckit-config",
@@ -3474,7 +3474,7 @@
3474 3474
3475 3475 [[package]]
3476 3476 name = "quasi-router"
3477 - version = "0.45.0"
3477 + version = "0.46.0"
3478 3478 dependencies = [
3479 3479 "makeover-layout",
3480 3480 ]
@@ -3491,7 +3491,7 @@
3491 3491
3492 3492 [[package]]
3493 3493 name = "quasi-tauri"
3494 - version = "0.45.0"
3494 + version = "0.46.0"
3495 3495 dependencies = [
3496 3496 "http",
3497 3497 "quasi-http",
@@ -3504,7 +3504,7 @@
3504 3504
3505 3505 [[package]]
3506 3506 name = "quasi-tui"
3507 - version = "0.45.0"
3507 + version = "0.46.0"
3508 3508 dependencies = [
3509 3509 "docengine",
3510 3510 "makeover",
@@ -3516,7 +3516,7 @@
3516 3516
3517 3517 [[package]]
3518 3518 name = "quasi-webview"
3519 - version = "0.45.0"
3519 + version = "0.46.0"
3520 3520 dependencies = [
3521 3521 "docengine",
3522 3522 "makeover-layout",
@@ -6189,11 +6189,7 @@
6189 6189
6190 6190 [[patch.unused]]
6191 6191 name = "synckit-client"
6192 - version = "0.8.0"
6193 -
6194 - [[patch.unused]]
6195 - name = "makeover-build"
6196 - version = "0.48.0"
6192 + version = "0.8.1"
6197 6193
6198 6194 [[patch.unused]]
6199 6195 name = "kberg"
@@ -6214,3 +6210,7 @@
6214 6210 [[patch.unused]]
6215 6211 name = "quasi-type"
6216 6212 version = "0.1.0"
6213 +
6214 + [[patch.unused]]
6215 + name = "makeover-build"
6216 + version = "0.48.0"
@@ -90,7 +90,7 @@
90 90 //! // `tauri.conf.json` and so is not part of this example.
91 91 //! ```
92 92
93 - use std::sync::Arc;
93 + use std::sync::{Arc, OnceLock};
94 94
95 95 use quasi_http::Refusal;
96 96 use quasi_router::{RouteError, Router};
@@ -136,11 +136,47 @@
136 136 /// means a passthrough cannot quietly become a second router.
137 137 type Passthrough = Box<dyn Fn(&str) -> Option<Served> + Send + Sync + 'static>;
138 138
139 + /// State a host cannot build until it has already started.
140 + ///
141 + /// Tauri is the case this exists for, and it is not an edge one. An app whose
142 + /// state needs a data directory needs an `AppHandle` to resolve it, and the
143 + /// handle does not exist until `Builder::build` runs — which is after every
144 + /// scheme is registered. So the protocol has to be handed to the builder
145 + /// before the thing it routes over can be made.
146 + ///
147 + /// [`Protocol::pending`] gives the builder its protocol now and the setup
148 + /// closure this, to fill in once. Requests that arrive before it is filled are
149 + /// answered 503 rather than blocked on: the window is not up yet, so there is
150 + /// nobody to keep waiting.
151 + pub struct Late<S>(Arc<OnceLock<Arc<S>>>);
152 +
153 + impl<S> Late<S> {
154 + /// Hand over the state. The first call wins.
155 + ///
156 + /// Returns whether this call was the one that set it, so a second caller
157 + /// can say so rather than silently doing nothing.
158 + pub fn set(&self, state: Arc<S>) -> bool {
159 + self.0.set(state).is_ok()
160 + }
161 +
162 + /// Whether the state has arrived.
163 + #[must_use]
164 + pub fn is_set(&self) -> bool {
165 + self.0.get().is_some()
166 + }
167 + }
168 +
169 + impl<S> Clone for Late<S> {
170 + fn clone(&self) -> Self {
171 + Self(Arc::clone(&self.0))
172 + }
173 + }
174 +
139 175 /// A scheme name, a router, the app's state and a renderer.
140 176 pub struct Protocol<S, R> {
141 177 scheme: String,
142 178 router: Router<S>,
143 - state: Arc<S>,
179 + state: Arc<OnceLock<Arc<S>>>,
144 180 render: Arc<R>,
145 181 body_limit: usize,
146 182 passthrough: Option<Passthrough>,
@@ -149,7 +185,7 @@
149 185 /// What a request needs, once, behind one `Arc`.
150 186 struct Context<S, R> {
151 187 router: Router<S>,
152 - state: Arc<S>,
188 + state: Arc<OnceLock<Arc<S>>>,
153 189 render: Arc<R>,
154 190 body_limit: usize,
155 191 passthrough: Option<Passthrough>,
@@ -184,16 +220,51 @@
184 220 is_scheme(&scheme),
185 221 "`{scheme}` is not a legal URL scheme: a letter, then letters, digits, `+`, `-` or `.`"
186 222 );
223 + let cell = OnceLock::new();
224 + let _ = cell.set(state);
187 225 Self {
188 226 scheme,
189 227 router,
190 - state,
228 + state: Arc::new(cell),
191 229 render,
192 230 body_limit: DEFAULT_BODY_LIMIT,
193 231 passthrough: None,
194 232 }
195 233 }
196 234
235 + /// The same, for a host whose state does not exist yet.
236 + ///
237 + /// Returns the protocol to register and the [`Late`] to fill in once the
238 + /// state can be built. See [`Late`] for why Tauri needs this at all.
239 + ///
240 + /// # Panics
241 + ///
242 + /// On the same illegal scheme [`Protocol::new`] rejects.
243 + #[must_use]
244 + pub fn pending(scheme: impl Into<String>, router: Router<S>, render: Arc<R>) -> (Self, Late<S>)
245 + where
246 + R: Serves,
247 + {
248 + let scheme = scheme.into();
249 + assert!(
250 + is_scheme(&scheme),
251 + "`{scheme}` is not a legal URL scheme: a letter, then letters, digits, `+`, `-` or `.`"
252 + );
253 + let state = Arc::new(OnceLock::new());
254 + let late = Late(Arc::clone(&state));
255 + (
256 + Self {
257 + scheme,
258 + router,
259 + state,
260 + render,
261 + body_limit: DEFAULT_BODY_LIMIT,
262 + passthrough: None,
263 + },
264 + late,
265 + )
266 + }
267 +
197 268 /// Read at most this many bytes of a form body.
198 269 #[must_use]
199 270 pub fn body_limit(mut self, bytes: usize) -> Self {
@@ -284,6 +355,16 @@
284 355 {
285 356 let path = request.uri().path();
286 357
358 + // Nothing to route over yet. Only reachable on the deferred path, and only
359 + // in the window between registering the scheme and setup filling it in.
360 + let Some(state) = context.state.get() else {
361 + return http::Response::builder()
362 + .status(503)
363 + .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
364 + .body(b"the app is still starting".to_vec())
365 + .unwrap_or_else(|_| quasi_http::refuse(Refusal::Malformed));
366 + };
367 +
287 368 if let Some(passthrough) = context.passthrough.as_ref()
288 369 && let Some(served) = passthrough(path)
289 370 {
@@ -310,7 +391,7 @@
310 391 // A hosted server gets this from its executor; here it has to be caught.
311 392 let asked = quasi_http::Asked::new(&incoming);
312 393 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
313 - context.router.handle(&context.state, incoming.into())
394 + context.router.handle(state, incoming.into())
314 395 }))
315 396 .unwrap_or_else(|_| Err(RouteError::internal("the request could not be completed")));
316 397
@@ -326,3 +326,31 @@
326 326 assert!(served.contains("hx-get=\"/task/1\""));
327 327 assert!(served.contains("id=\"detail\""));
328 328 }
329 +
330 + #[test]
331 + fn a_pending_protocol_answers_503_until_its_state_lands() {
332 + let (protocol, late) = Protocol::<App, Spy>::pending("quasi", router(), Arc::new(Spy));
333 + let ctx = context(protocol);
334 +
335 + // The window between registering the scheme and setup filling the state in.
336 + assert!(!late.is_set());
337 + let early = serve(&ctx, &get("quasi://localhost/"));
338 + assert_eq!(early.status(), 503);
339 + assert!(text(&early).contains("starting"));
340 +
341 + assert!(late.set(Arc::new(App)), "the first set wins");
342 + assert!(late.is_set());
343 + let served = serve(&ctx, &get("quasi://localhost/"));
344 + assert_eq!(served.status(), 200);
345 + assert_eq!(text(&served), "screen:Home");
346 +
347 + // A second hand-over changes nothing rather than swapping the app out from
348 + // under a request already in flight.
349 + assert!(!late.set(Arc::new(App)));
350 + }
351 +
352 + #[test]
353 + fn a_pending_protocol_names_the_same_url_as_an_eager_one() {
354 + let (protocol, _late) = Protocol::<App, Spy>::pending("quasi", router(), Arc::new(Spy));
355 + assert_eq!(protocol.url().as_str(), "quasi://localhost/");
356 + }