1use crate::security::{self, Security};
2use axum::{
3 extract::{Path, Query, State},
4 http::StatusCode,
5 middleware::from_fn_with_state,
6 response::{
7 sse::{Event, Sse},
8 IntoResponse,
9 },
10 routing::{get, post},
11 Json, Router,
12};
13use futures_util::{
14 stream::{self, Stream},
15 StreamExt,
16};
17use rpi_extcore::{
18 durable::{DurableMutation, QueueStatus},
19 host::BufferedEvent,
20 protocol::{self, ApiCommand},
21 reply::{IntoReply, ReplyKind, ServiceReply},
22 service::{
23 self, AuthRefreshRequest, BackendConfig, CreateProjectRequest, CreateSessionRequest,
24 FileQuery, ForkRequest, MetadataRequest, ProjectFoldersQuery, RenameRequest, Service,
25 SessionListQuery,
26 },
27};
28use serde::{de::DeserializeOwned, Deserialize};
29use serde_json::{json, Value};
30use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
31use tokio::sync::{broadcast, watch};
32#[derive(Clone, Debug, serde::Deserialize)]
33#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
34pub struct AppConfig {
35 #[serde(skip, default = "default_service_name")]
36 pub service_name: &'static str,
37 pub project: PathBuf,
38 pub projects: Vec<PathBuf>,
40 pub projects_root: Option<PathBuf>,
41 pub rpi_bin: PathBuf,
42 pub bind: String,
43 pub port: u16,
44 pub allowed_hosts: Option<Vec<String>>,
46 pub session_dir: Option<PathBuf>,
48 pub registry: PathBuf,
50}
51
52fn default_service_name() -> &'static str {
53 "rpi-http"
54}
55
56impl Default for AppConfig {
57 fn default() -> Self {
58 Self {
59 service_name: "rpi-http",
60 project: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
61 projects: Vec::new(),
62 projects_root: None,
63 rpi_bin: PathBuf::from("rpi"),
64 bind: "127.0.0.1".into(),
65 port: 3030,
66 allowed_hosts: None,
67 session_dir: None,
68 registry: rpi_extcore::registry::default_path(),
69 }
70 }
71}
72
73#[derive(Clone)]
74struct AppState {
75 backend: Arc<Service>,
76 security: Arc<Security>,
77 shutdown: watch::Receiver<bool>,
78 service_name: &'static str,
79}
80pub struct Listening {
81 pub address: SocketAddr,
82 pub hosts: Vec<String>,
83 pub password_enabled: bool,
84}
85struct HttpReply(ServiceReply);
86impl IntoResponse for HttpReply {
87 fn into_response(self) -> axum::response::Response {
88 let status = match self.0.kind {
89 ReplyKind::Success => StatusCode::OK,
90 ReplyKind::Created => StatusCode::CREATED,
91 ReplyKind::InvalidInput => StatusCode::BAD_REQUEST,
92 ReplyKind::NotFound => StatusCode::NOT_FOUND,
93 ReplyKind::AccessDenied => StatusCode::FORBIDDEN,
94 ReplyKind::LimitExceeded => StatusCode::PAYLOAD_TOO_LARGE,
95 ReplyKind::UnsupportedContent => StatusCode::UNSUPPORTED_MEDIA_TYPE,
96 ReplyKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
97 ReplyKind::HostUnavailable => StatusCode::BAD_GATEWAY,
98 ReplyKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
99 ReplyKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
100 ReplyKind::Conflict => StatusCode::CONFLICT,
101 ReplyKind::Empty => StatusCode::NO_CONTENT,
102 ReplyKind::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
103 };
104 if matches!(self.0.kind, ReplyKind::Empty) {
105 status.into_response()
106 } else {
107 (status, Json(self.0.value)).into_response()
108 }
109 }
110}
111pub async fn run(config: AppConfig) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
112 serve_until(
113 config,
114 Router::new(),
115 |info| println!("rpi HTTP API listening on {}", info.address),
116 async {
117 let _ = tokio::signal::ctrl_c().await;
118 },
119 )
120 .await
121}
122pub async fn serve_until(
123 config: AppConfig,
124 frontend: Router,
125 on_listen: impl FnOnce(Listening),
126 stop: impl std::future::Future<Output = ()> + Send + 'static,
127) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
128 let mut config = config;
129 let requested = SocketAddr::new(config.bind.parse()?, config.port);
130 let listener = tokio::net::TcpListener::bind(requested)
131 .await
132 .map_err(|error| {
133 std::io::Error::new(
134 error.kind(),
135 format!("cannot listen on {requested}: {error}"),
136 )
137 })?;
138 let address = listener.local_addr()?;
139 config.port = address.port();
140 let backend = Service::new(BackendConfig {
141 project: config.project.clone(),
142 projects: config.projects.clone(),
143 projects_root: config.projects_root.clone(),
144 rpi_bin: config.rpi_bin.clone(),
145 session_dir: config.session_dir.clone(),
146 registry: config.registry.clone(),
147 })?;
148 let hosts = allowed_hosts(&config);
149 let password = configured_password(&config);
150 let security = Arc::new(Security::new(hosts.clone(), password.as_deref()));
151 let (shutdown, receiver) = watch::channel(false);
152 let state = AppState {
153 backend,
154 security: security.clone(),
155 shutdown: receiver,
156 service_name: config.service_name,
157 };
158 let router = routes(state.clone())
159 .merge(frontend)
160 .layer(from_fn_with_state(security, crate::security::guard));
161 on_listen(Listening {
162 address,
163 hosts,
164 password_enabled: state.security.enabled(),
165 });
166 std::io::Write::flush(&mut std::io::stdout())?;
167 let mut shutdown_receiver = state.shutdown.clone();
168 let serving = std::future::IntoFuture::into_future(
169 axum::serve(listener, router).with_graceful_shutdown(async move {
170 if !*shutdown_receiver.borrow_and_update() {
171 let _ = shutdown_receiver.changed().await;
172 }
173 }),
174 );
175 tokio::pin!(serving);
176 tokio::select! {
177 result = &mut serving => result?,
178 _ = stop => {
179 println!(" Status Stopping...");
180 let _ = shutdown.send(true);
181 let _ = tokio::time::timeout(std::time::Duration::from_secs(8), async {
182 state.backend.shutdown().await;
183 let _ = serving.await;
184 }).await;
185 }
186 }
187 println!(" Status Stopped");
188 Ok(())
189}
190
191pub fn router(
194 backend: Arc<Service>,
195 config: &AppConfig,
196 shutdown: watch::Receiver<bool>,
197) -> Router {
198 let security = Arc::new(Security::new(
199 allowed_hosts(config),
200 configured_password(config).as_deref(),
201 ));
202 let state = AppState {
203 backend,
204 security: security.clone(),
205 shutdown,
206 service_name: config.service_name,
207 };
208 routes(state).layer(from_fn_with_state(security, crate::security::guard))
209}
210fn routes(state: AppState) -> Router {
211 Router::new()
212 .route("/api/v1/health", get(health))
213 .route("/api/v1/capabilities", get(capabilities))
214 .route("/api/v1/projects", get(list_projects).post(create_project))
215 .route("/api/v1/project-folders", get(list_project_folders))
216 .route("/api/v1/projects/{id}/files", get(project_files))
217 .route("/api/v1/projects/{id}/file", get(project_file))
218 .route("/api/v1/projects/{id}/git/status", get(project_status))
219 .route("/api/v1/projects/{id}/git/diff", get(project_diff))
220 .route(
221 "/api/v1/auth",
222 get(auth_status).post(auth_login).delete(auth_logout),
223 )
224 .route("/api/v1/auth/refresh", post(refresh_auth))
225 .route("/api/v1/sessions", post(create_session).get(list_sessions))
226 .route(
227 "/api/v1/sessions/{id}",
228 get(get_session).delete(delete_session),
229 )
230 .route("/api/v1/sessions/{id}/open", post(open_session))
231 .route("/api/v1/sessions/{id}/fork", post(fork_session))
232 .route("/api/v1/sessions/{id}/clone", post(clone_session))
233 .route("/api/v1/sessions/{id}/rename", post(rename_session))
234 .route("/api/v1/sessions/{id}/metadata", post(session_metadata))
235 .route("/api/v1/sessions/{id}/commands", post(command))
236 .route("/api/v1/sessions/{id}/snapshot", get(snapshot))
237 .route(
238 "/api/v1/sessions/{id}/durable",
239 get(durable_state).post(durable_mutation),
240 )
241 .route("/api/v1/sessions/{id}/durable/events", get(durable_events))
242 .route("/api/v1/sessions/{id}/events", get(events))
243 .with_state(state)
244}
245
246fn configured_password(config: &AppConfig) -> Option<String> {
247 let (primary, legacy) = if config.service_name == "rpi-web" {
248 ("RPI_WEB_PASSWORD", "RPI_HTTP_PASSWORD")
249 } else {
250 ("RPI_HTTP_PASSWORD", "RPI_WEB_PASSWORD")
251 };
252 std::env::var(primary)
253 .or_else(|_| std::env::var(legacy))
254 .ok()
255 .map(|value| value.trim().to_string())
256 .filter(|value| !value.is_empty())
257}
258
259fn allowed_hosts(config: &AppConfig) -> Vec<String> {
260 let (primary, legacy) = if config.service_name == "rpi-web" {
261 ("RPI_WEB_ALLOWED_HOSTS", "RPI_HTTP_ALLOWED_HOSTS")
262 } else {
263 ("RPI_HTTP_ALLOWED_HOSTS", "RPI_WEB_ALLOWED_HOSTS")
264 };
265 std::env::var(primary)
266 .or_else(|_| std::env::var(legacy))
267 .ok()
268 .map(|value| {
269 value
270 .split(',')
271 .map(str::trim)
272 .filter(|host| !host.is_empty())
273 .map(str::to_string)
274 .collect()
275 })
276 .filter(|hosts: &Vec<String>| !hosts.is_empty())
277 .or_else(|| config.allowed_hosts.clone())
278 .unwrap_or_else(|| {
279 let mut hosts = vec![
280 format!("127.0.0.1:{}", config.port),
281 format!("localhost:{}", config.port),
282 ];
283 if let Ok(ip) = config.bind.parse::<std::net::IpAddr>() {
284 if ip.is_ipv6() {
285 hosts.push(format!("[::1]:{}", config.port));
286 }
287 if !ip.is_unspecified() {
288 let authority = SocketAddr::new(ip, config.port).to_string();
289 if !hosts.contains(&authority) {
290 hosts.push(authority);
291 }
292 }
293 }
294 hosts
295 })
296}
297
298async fn auth_status(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Json<Value> {
299 security::status_for(&state.security, &headers).await
300}
301
302async fn auth_login(
303 State(state): State<AppState>,
304 headers: axum::http::HeaderMap,
305 Json(request): Json<security::LoginRequest>,
306) -> impl IntoResponse {
307 security::login_for(&state.security, &headers, request).await
308}
309
310async fn auth_logout(
311 State(state): State<AppState>,
312 headers: axum::http::HeaderMap,
313) -> axum::response::Response {
314 security::logout_for(&state.security, &headers).await
315}
316
317async fn health(State(state): State<AppState>) -> Json<Value> {
318 Json(json!({"ok":true,"service":state.service_name,"protocol":"rpi"}))
319}
320async fn capabilities() -> Json<Value> {
321 Json(protocol::capabilities())
322}
323async fn list_projects(State(state): State<AppState>) -> axum::response::Response {
324 HttpReply(service::list_projects(&state.backend).await.into_reply()).into_response()
325}
326
327async fn list_project_folders(
328 State(state): State<AppState>,
329 Query(query): Query<ProjectFoldersQuery>,
330) -> axum::response::Response {
331 HttpReply(
332 service::list_project_folders(&state.backend, query)
333 .await
334 .into_reply(),
335 )
336 .into_response()
337}
338
339async fn create_project(
340 State(state): State<AppState>,
341 Json(request): Json<CreateProjectRequest>,
342) -> axum::response::Response {
343 HttpReply(
344 service::create_project(&state.backend, request)
345 .await
346 .into_reply(),
347 )
348 .into_response()
349}
350
351async fn project_files(
352 State(state): State<AppState>,
353 Path(id): Path<String>,
354 Query(query): Query<FileQuery>,
355) -> axum::response::Response {
356 HttpReply(
357 service::project_files(&state.backend, id, query)
358 .await
359 .into_reply(),
360 )
361 .into_response()
362}
363
364async fn project_file(
365 State(state): State<AppState>,
366 Path(id): Path<String>,
367 Query(query): Query<FileQuery>,
368) -> axum::response::Response {
369 HttpReply(
370 service::project_file(&state.backend, id, query)
371 .await
372 .into_reply(),
373 )
374 .into_response()
375}
376
377async fn project_status(
378 State(state): State<AppState>,
379 Path(id): Path<String>,
380) -> axum::response::Response {
381 HttpReply(
382 service::project_status(&state.backend, id)
383 .await
384 .into_reply(),
385 )
386 .into_response()
387}
388
389async fn project_diff(
390 State(state): State<AppState>,
391 Path(id): Path<String>,
392 Query(query): Query<FileQuery>,
393) -> axum::response::Response {
394 HttpReply(
395 service::project_diff(&state.backend, id, query)
396 .await
397 .into_reply(),
398 )
399 .into_response()
400}
401
402async fn refresh_auth(
403 State(state): State<AppState>,
404 Json(request): Json<AuthRefreshRequest>,
405) -> axum::response::Response {
406 HttpReply(
407 service::refresh_auth(&state.backend, request)
408 .await
409 .into_reply(),
410 )
411 .into_response()
412}
413
414async fn create_session(
415 State(state): State<AppState>,
416 body: axum::body::Bytes,
417) -> axum::response::Response {
418 let request: CreateSessionRequest = match optional_json(&body) {
419 Ok(request) => request,
420 Err(error) => return error.into_response(),
421 };
422 HttpReply(
423 service::create_session(&state.backend, request)
424 .await
425 .into_reply(),
426 )
427 .into_response()
428}
429
430async fn rename_session(
431 State(state): State<AppState>,
432 Path(id): Path<String>,
433 Json(request): Json<RenameRequest>,
434) -> axum::response::Response {
435 HttpReply(
436 service::rename_session(&state.backend, id, request)
437 .await
438 .into_reply(),
439 )
440 .into_response()
441}
442
443async fn session_metadata(
444 State(state): State<AppState>,
445 Path(id): Path<String>,
446 Json(request): Json<MetadataRequest>,
447) -> axum::response::Response {
448 HttpReply(
449 service::session_metadata(&state.backend, id, request)
450 .await
451 .into_reply(),
452 )
453 .into_response()
454}
455
456async fn fork_session(
457 State(state): State<AppState>,
458 Path(id): Path<String>,
459 Json(request): Json<ForkRequest>,
460) -> axum::response::Response {
461 HttpReply(
462 service::fork_session(&state.backend, id, request)
463 .await
464 .into_reply(),
465 )
466 .into_response()
467}
468
469async fn clone_session(
470 State(state): State<AppState>,
471 Path(id): Path<String>,
472 body: axum::body::Bytes,
473) -> axum::response::Response {
474 let request: ForkRequest = match optional_json(&body) {
475 Ok(request) => request,
476 Err(error) => return error.into_response(),
477 };
478 HttpReply(
479 service::clone_session(&state.backend, id, request)
480 .await
481 .into_reply(),
482 )
483 .into_response()
484}
485
486async fn open_session(
487 State(state): State<AppState>,
488 Path(id): Path<String>,
489) -> axum::response::Response {
490 HttpReply(service::open_session(&state.backend, id).await.into_reply()).into_response()
491}
492
493async fn list_sessions(
494 State(state): State<AppState>,
495 Query(query): Query<SessionListQuery>,
496) -> axum::response::Response {
497 HttpReply(
498 service::list_sessions(&state.backend, query)
499 .await
500 .into_reply(),
501 )
502 .into_response()
503}
504
505async fn get_session(
506 State(state): State<AppState>,
507 Path(id): Path<String>,
508) -> axum::response::Response {
509 HttpReply(service::get_session(&state.backend, id).await.into_reply()).into_response()
510}
511
512async fn delete_session(
513 State(state): State<AppState>,
514 Path(id): Path<String>,
515) -> axum::response::Response {
516 HttpReply(
517 service::delete_session(&state.backend, id)
518 .await
519 .into_reply(),
520 )
521 .into_response()
522}
523
524async fn command(
525 State(state): State<AppState>,
526 Path(id): Path<String>,
527 Json(command): Json<ApiCommand>,
528) -> axum::response::Response {
529 HttpReply(
530 service::command(&state.backend, id, command)
531 .await
532 .into_reply(),
533 )
534 .into_response()
535}
536
537#[derive(Debug, Deserialize)]
538#[serde(rename_all = "camelCase", deny_unknown_fields)]
539struct DurableMutationRequest {
540 operation: String,
541 #[serde(default)]
542 text: Option<String>,
543 #[serde(default)]
544 item_id: Option<String>,
545 #[serde(default)]
546 status: Option<String>,
547 #[serde(default)]
548 note: Option<String>,
549}
550
551fn parse_durable_mutation(request: DurableMutationRequest) -> Result<DurableMutation, String> {
552 let item_id = || {
553 request
554 .item_id
555 .clone()
556 .filter(|value| !value.trim().is_empty())
557 .ok_or_else(|| "itemId is required".to_string())
558 };
559 match request.operation.trim() {
560 "start_run" => Ok(DurableMutation::StartRun),
561 "settle_run" => Ok(DurableMutation::SettleRun),
562 "recover_run" => Ok(DurableMutation::RecoverRun),
563 "enqueue" => Ok(DurableMutation::Enqueue {
564 text: request
565 .text
566 .filter(|value| !value.trim().is_empty())
567 .ok_or_else(|| "text is required".to_string())?,
568 }),
569 "claim" => Ok(DurableMutation::Claim { id: item_id()? }),
570 "finish" => {
571 let status = match request.status.as_deref() {
572 Some("completed") => QueueStatus::Completed,
573 Some("failed") => QueueStatus::Failed,
574 Some(other) => return Err(format!("invalid finish status: {other}")),
575 None => return Err("status is required for finish".to_string()),
576 };
577 Ok(DurableMutation::Finish {
578 id: item_id()?,
579 status,
580 note: request.note.unwrap_or_default(),
581 })
582 }
583 "cancel" => Ok(DurableMutation::Cancel {
584 id: item_id()?,
585 note: request.note.unwrap_or_default(),
586 }),
587 other => Err(format!("unsupported durable operation: {other}")),
588 }
589}
590
591async fn durable_state(
592 State(state): State<AppState>,
593 Path(id): Path<String>,
594) -> axum::response::Response {
595 HttpReply(
596 service::durable_state(&state.backend, id)
597 .await
598 .into_reply(),
599 )
600 .into_response()
601}
602
603async fn durable_mutation(
604 State(state): State<AppState>,
605 Path(id): Path<String>,
606 Json(request): Json<DurableMutationRequest>,
607) -> axum::response::Response {
608 let mutation = match parse_durable_mutation(request) {
609 Ok(mutation) => mutation,
610 Err(error) => {
611 return (StatusCode::BAD_REQUEST, Json(json!({"error": error}))).into_response()
612 }
613 };
614 HttpReply(
615 service::mutate_durable(&state.backend, id, mutation)
616 .await
617 .into_reply(),
618 )
619 .into_response()
620}
621
622async fn snapshot(
623 State(state): State<AppState>,
624 Path(id): Path<String>,
625 Query(options): Query<service::SnapshotOptions>,
626) -> axum::response::Response {
627 HttpReply(
628 service::snapshot_with_options(&state.backend, id, options)
629 .await
630 .into_reply(),
631 )
632 .into_response()
633}
634
635fn optional_json<T: DeserializeOwned + Default>(
638 body: &[u8],
639) -> Result<T, (StatusCode, Json<Value>)> {
640 if body.is_empty() {
641 return Ok(T::default());
642 }
643 serde_json::from_slice(body).map_err(|_| {
644 (
645 StatusCode::BAD_REQUEST,
646 Json(json!({"error":"invalid JSON request body"})),
647 )
648 })
649}
650
651#[derive(Debug, serde::Deserialize)]
652struct EventQuery {
653 after: Option<u64>,
654}
655
656async fn durable_events(
657 State(state): State<AppState>,
658 Path(id): Path<String>,
659 headers: axum::http::HeaderMap,
660 Query(query): Query<EventQuery>,
661) -> axum::response::Response {
662 let after = headers
663 .get("last-event-id")
664 .and_then(|value| value.to_str().ok())
665 .and_then(|value| value.parse::<u64>().ok())
666 .or(query.after);
667 let Some(subscription) = state.backend.durable_subscription(&id, after).await else {
668 let status = if state.backend.live_session(&id).await.is_some() {
669 StatusCode::SERVICE_UNAVAILABLE
670 } else {
671 StatusCode::NOT_FOUND
672 };
673 return (
674 status,
675 Json(json!({
676 "error": if status == StatusCode::NOT_FOUND {
677 "session not found"
678 } else {
679 "durable unavailable"
680 },
681 "code": if status == StatusCode::NOT_FOUND {
682 "session_not_found"
683 } else {
684 "durable_unavailable"
685 }
686 })),
687 )
688 .into_response();
689 };
690 let mut shutdown = state.shutdown;
691 Sse::new(
692 authorized_events(
693 durable_event_stream(id, subscription),
694 state.security,
695 headers,
696 )
697 .take_until(async move {
698 if !*shutdown.borrow_and_update() {
699 let _ = shutdown.changed().await;
700 }
701 }),
702 )
703 .keep_alive(axum::response::sse::KeepAlive::default())
704 .into_response()
705}
706
707fn durable_event_stream(
708 session_id: String,
709 subscription: rpi_extcore::durable::DurableSubscription,
710) -> impl Stream<Item = Result<Event, Infallible>> {
711 let rpi_extcore::durable::DurableSubscription {
712 ready,
713 replay,
714 receiver,
715 gap,
716 after,
717 } = subscription;
718 let mut head = Vec::new();
719 if after.is_none() {
720 head.push(Ok(Event::default()
721 .event("durable_ready")
722 .id(ready.revision.to_string())
723 .data(
724 json!({
725 "sessionId": session_id,
726 "revision": ready.revision,
727 "state": ready,
728 })
729 .to_string(),
730 )));
731 }
732 if gap {
733 head.push(Ok(Event::default().event("resync_required").data(
734 json!({
735 "type": "resync_required",
736 "code": "durable_event_gap"
737 })
738 .to_string(),
739 )));
740 }
741 stream::iter(head).chain(
742 rpi_extcore::durable::replay_changes(replay, receiver, after.or(Some(ready.revision))).map(
743 move |item| {
744 Ok(match item {
745 rpi_extcore::durable::DurableStreamItem::Change(change) => Event::default()
746 .event("durable_change")
747 .id(change.revision.to_string())
748 .data(
749 json!({
750 "sessionId": session_id,
751 "revision": change.revision,
752 "state": change.state,
753 })
754 .to_string(),
755 ),
756 rpi_extcore::durable::DurableStreamItem::ResyncRequired { dropped } => {
757 Event::default().event("resync_required").data(
758 json!({
759 "type": "resync_required",
760 "code": "durable_event_stream_lagged",
761 "dropped": dropped
762 })
763 .to_string(),
764 )
765 }
766 })
767 },
768 ),
769 )
770}
771
772async fn events(
773 State(state): State<AppState>,
774 Path(id): Path<String>,
775 headers: axum::http::HeaderMap,
776 Query(query): Query<EventQuery>,
777) -> axum::response::Response {
778 let after = headers
779 .get("last-event-id")
780 .and_then(|value| value.to_str().ok())
781 .and_then(|value| value.parse::<u64>().ok())
782 .or(query.after);
783 let Some(subscription) = state.backend.subscribe_events(&id, after).await else {
784 return (
785 StatusCode::NOT_FOUND,
786 Json(json!({"error":"session not found"})),
787 )
788 .into_response();
789 };
790 let mut shutdown = state.shutdown;
791 Sse::new(
792 authorized_events(
793 event_stream(
794 subscription.ready,
795 subscription.replay,
796 subscription.receiver,
797 subscription.gap,
798 subscription.after,
799 ),
800 state.security,
801 headers,
802 )
803 .take_until(async move {
804 if !*shutdown.borrow_and_update() {
805 let _ = shutdown.changed().await;
806 }
807 }),
808 )
809 .keep_alive(axum::response::sse::KeepAlive::default())
810 .into_response()
811}
812
813fn event_stream(
814 ready: Value,
815 replay: Vec<BufferedEvent>,
816 receiver: broadcast::Receiver<BufferedEvent>,
817 gap: bool,
818 after: Option<u64>,
819) -> impl Stream<Item = Result<Event, Infallible>> {
820 let head: Vec<Result<Event, Infallible>> =
821 std::iter::once(Ok(Event::default().event("ready").data(ready.to_string())))
822 .chain(gap.then(|| {
823 Ok(Event::default()
824 .event("resync_required")
825 .data("{\"type\":\"resync_required\"}"))
826 }))
827 .collect();
828 stream::iter(head).chain(rpi_extcore::events::replay_stream(replay,receiver,after).map(|item| {
829 Ok(match item {
830 rpi_extcore::events::StreamItem::Event(event)=>to_sse(event),
831 rpi_extcore::events::StreamItem::ResyncRequired{dropped}=>Event::default().event("resync_required").data(json!({"type":"resync_required","code":"event_stream_lagged","dropped":dropped}).to_string()),
832 })
833 }))
834}
835
836fn authorized_events(
839 events: impl Stream<Item = Result<Event, Infallible>> + Send + 'static,
840 security: Arc<Security>,
841 headers: axum::http::HeaderMap,
842) -> impl Stream<Item = Result<Event, Infallible>> {
843 let expiry_check = tokio::time::interval(std::time::Duration::from_secs(10));
844 stream::unfold(
845 (Box::pin(events), expiry_check),
846 move |(mut events, mut expiry_check)| {
847 let security = security.clone();
848 let headers = headers.clone();
849 async move {
850 loop {
851 tokio::select! {
852 event = events.next() => {
853 let event = event?;
854 if !security.authorized(&headers).await { return None; }
855 return Some((event, (events, expiry_check)));
856 }
857 _ = expiry_check.tick() => {
858 if !security.authorized(&headers).await { return None; }
859 }
860 }
861 }
862 }
863 },
864 )
865}
866
867fn to_sse(mut event: BufferedEvent) -> Event {
868 rpi_extcore::timing::refresh_event(&mut event.value);
869 let event_type = event
870 .value
871 .get("type")
872 .and_then(Value::as_str)
873 .unwrap_or("message");
874 Event::default()
875 .event(event_type)
876 .id(event.seq.to_string())
877 .data(event.value.to_string())
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883
884 #[test]
885 fn durable_requests_map_to_protocol_neutral_mutations() {
886 let mutation = parse_durable_mutation(DurableMutationRequest {
887 operation: "enqueue".into(),
888 text: Some("run tests".into()),
889 item_id: None,
890 status: None,
891 note: None,
892 })
893 .unwrap();
894 assert!(matches!(mutation, DurableMutation::Enqueue { text } if text == "run tests"));
895
896 let mutation = parse_durable_mutation(DurableMutationRequest {
897 operation: "finish".into(),
898 text: None,
899 item_id: Some("item-1".into()),
900 status: Some("completed".into()),
901 note: Some("passed".into()),
902 })
903 .unwrap();
904 assert!(matches!(
905 mutation,
906 DurableMutation::Finish {
907 id,
908 status: QueueStatus::Completed,
909 note
910 } if id == "item-1" && note == "passed"
911 ));
912 }
913
914 #[test]
915 fn durable_requests_reject_invalid_operations_and_statuses() {
916 let error = parse_durable_mutation(DurableMutationRequest {
917 operation: "finish".into(),
918 text: None,
919 item_id: Some("item-1".into()),
920 status: Some("queued".into()),
921 note: None,
922 })
923 .unwrap_err();
924 assert!(error.contains("invalid finish status"));
925 }
926
927 #[test]
928 fn optional_creation_bodies_reject_invalid_json_and_types() {
929 assert!(optional_json::<CreateSessionRequest>(b"").is_ok());
930 assert!(optional_json::<CreateSessionRequest>(br#"{"projectId":"project-a"}"#).is_ok());
931 for body in [b"{".as_slice(), b"null", br#"{"projectId":42}"#] {
932 assert_eq!(
933 optional_json::<CreateSessionRequest>(body).unwrap_err().0,
934 StatusCode::BAD_REQUEST
935 );
936 }
937 assert!(optional_json::<ForkRequest>(b"").is_ok());
938 assert!(optional_json::<ForkRequest>(br#"{"entryId":42}"#).is_err());
939 }
940
941 #[tokio::test]
942 async fn live_events_do_not_duplicate_replay_or_the_snapshot_cursor() {
943 for replay in [
944 vec![],
945 vec![BufferedEvent {
946 seq: 5,
947 value: json!({"type":"agent_start"}),
948 }],
949 ] {
950 let (sender, receiver) = broadcast::channel(8);
951 for seq in [4, 5, 6] {
952 sender
953 .send(BufferedEvent {
954 seq,
955 value: json!({"type":"agent_start"}),
956 })
957 .unwrap();
958 }
959 drop(sender);
960 let has_replay = !replay.is_empty();
961 let stream = event_stream(json!({"type":"ready"}), replay, receiver, false, Some(5));
962 let body = axum::body::to_bytes(Sse::new(stream).into_response().into_body(), 4096)
963 .await
964 .unwrap();
965 let text = String::from_utf8(body.to_vec()).unwrap();
966 assert!(!text.contains("id: 4\n"));
967 assert_eq!(text.matches("id: 5\n").count(), usize::from(has_replay));
968 assert_eq!(text.matches("id: 6\n").count(), 1);
969 }
970 }
971
972 #[tokio::test]
973 async fn durable_events_emit_ready_changes_and_reconnect_replay() {
974 use rpi_extcore::durable::{DurableChange, DurableState, DurableSubscription};
975
976 let (sender, receiver) = broadcast::channel(8);
977 sender
978 .send(DurableChange {
979 revision: 1,
980 state: DurableState {
981 revision: 1,
982 ..DurableState::default()
983 },
984 })
985 .unwrap();
986 drop(sender);
987 let body = axum::body::to_bytes(
988 Sse::new(durable_event_stream(
989 "session-1".into(),
990 DurableSubscription {
991 ready: DurableState::default(),
992 replay: vec![],
993 receiver,
994 gap: false,
995 after: None,
996 },
997 ))
998 .into_response()
999 .into_body(),
1000 4096,
1001 )
1002 .await
1003 .unwrap();
1004 let text = String::from_utf8(body.to_vec()).unwrap();
1005 assert!(text.contains("event: durable_ready\n"));
1006 assert!(text.contains("event: durable_change\n"));
1007 assert!(text.contains("id: 1\n"));
1008
1009 let (sender, receiver) = broadcast::channel(8);
1010 drop(sender);
1011 let body = axum::body::to_bytes(
1012 Sse::new(durable_event_stream(
1013 "session-1".into(),
1014 DurableSubscription {
1015 ready: DurableState {
1016 revision: 1,
1017 ..DurableState::default()
1018 },
1019 replay: vec![DurableChange {
1020 revision: 1,
1021 state: DurableState {
1022 revision: 1,
1023 ..DurableState::default()
1024 },
1025 }],
1026 receiver,
1027 gap: false,
1028 after: Some(0),
1029 },
1030 ))
1031 .into_response()
1032 .into_body(),
1033 4096,
1034 )
1035 .await
1036 .unwrap();
1037 let text = String::from_utf8(body.to_vec()).unwrap();
1038 assert!(!text.contains("event: durable_ready\n"));
1039 assert_eq!(text.matches("id: 1\n").count(), 1);
1040 }
1041
1042 #[tokio::test]
1043 async fn logout_revokes_an_already_open_event_stream() {
1044 let security = Arc::new(Security::new(["example.test".into()], Some("secret")));
1045 let token = security.login("secret").await.unwrap();
1046 let mut headers = axum::http::HeaderMap::new();
1047 headers.insert(
1048 axum::http::header::COOKIE,
1049 format!("rpi_web_session={token}").parse().unwrap(),
1050 );
1051 let (sender, receiver) = broadcast::channel(8);
1052 let stream = authorized_events(
1053 event_stream(json!({"type":"ready"}), vec![], receiver, false, None),
1054 security.clone(),
1055 headers.clone(),
1056 );
1057 futures_util::pin_mut!(stream);
1058 assert!(stream.next().await.is_some());
1059 security.logout(&headers).await;
1060 sender
1061 .send(BufferedEvent {
1062 seq: 1,
1063 value: json!({"type":"message_end","message":"private"}),
1064 })
1065 .unwrap();
1066 assert!(stream.next().await.is_none());
1067 }
1068}