mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 12:54:09 +02:00
Add the Yaak Bridge so a browser tab can run the real engine
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
93001e3da7
commit
e294e6bcef
@@ -0,0 +1,355 @@
|
||||
//! The front door: one HTTP surface for the browser tab.
|
||||
//!
|
||||
//! Three routes carry everything. `POST /rpc` is the yaak-rpc envelope, byte for
|
||||
//! byte what the desktop puts inside Tauri's `invoke`. `GET /events` is the
|
||||
//! WebSocket that replaces window events, in both directions. And
|
||||
//! `GET /responses/:id/body` replaces reading `bodyPath` off disk, which a tab
|
||||
//! cannot do.
|
||||
|
||||
use crate::events::EventFrame;
|
||||
use crate::rpc::BridgeCtx;
|
||||
use crate::session::SessionContext;
|
||||
use crate::state::BridgeState;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, Query, Request, State};
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use yaak_rpc::{RpcRequest, RpcResponse, RpcRouter};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub state: Arc<BridgeState>,
|
||||
pub router: Arc<RpcRouter<BridgeCtx>>,
|
||||
}
|
||||
|
||||
pub fn build_app(state: Arc<BridgeState>, router: Arc<RpcRouter<BridgeCtx>>) -> Router {
|
||||
let app_state = AppState { state: state.clone(), router };
|
||||
|
||||
let api = Router::new()
|
||||
.route("/bridge/info", get(bridge_info))
|
||||
.route("/rpc", post(rpc_handler))
|
||||
.route("/events", get(events_handler))
|
||||
.route("/responses/:id/body", get(response_body))
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), require_token))
|
||||
// The dev setup serves the frontend from Vite on another port, so the
|
||||
// tab's origin is not the bridge's. Credentials never ride on cookies
|
||||
// here — the token is explicit — so a permissive CORS layer is safe and
|
||||
// is bounded by the token check that runs before it.
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(app_state);
|
||||
|
||||
match std::env::var("YAAK_BRIDGE_WEB_DIR").ok() {
|
||||
// Serving the built frontend makes the bridge a single process to run.
|
||||
// `index.html` is the fallback because the router owns the paths.
|
||||
Some(dir) => api.fallback_service(
|
||||
tower_http::services::ServeDir::new(&dir)
|
||||
.fallback(tower_http::services::ServeFile::new(format!("{dir}/index.html"))),
|
||||
),
|
||||
None => api,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Auth --
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenQuery {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// Dev-grade bearer check on every route.
|
||||
///
|
||||
/// The header is the normal path. The query parameter exists because two of
|
||||
/// these are opened by the browser itself — the WebSocket and the `<img src>`
|
||||
/// pointing at a response body — and neither lets the page set headers.
|
||||
///
|
||||
/// This is the seam where OTP pairing and per-session keys go. It is not one
|
||||
/// today: the token is a process-lifetime shared secret, and anything that can
|
||||
/// read the tab's URL can read it.
|
||||
async fn require_token(
|
||||
State(state): State<Arc<BridgeState>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let from_header = request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|v| v.to_string());
|
||||
|
||||
let from_query = request
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|q| serde_urlencoded::from_str::<TokenQuery>(q).ok())
|
||||
.and_then(|q| q.token);
|
||||
|
||||
let presented = from_header.or(from_query);
|
||||
|
||||
match presented {
|
||||
Some(token) if constant_time_eq(&token, &state.token) => next.run(request).await,
|
||||
_ => (StatusCode::UNAUTHORIZED, "Invalid or missing bridge token").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares without returning early on the first differing byte, so a caller
|
||||
/// can't learn the token one character at a time.
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
// -- Routes --
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BridgeInfo {
|
||||
name: String,
|
||||
version: String,
|
||||
capabilities: crate::state::BridgeCapabilities,
|
||||
/// Commands this build implements. The browser host uses it to fail fast
|
||||
/// with a clear message instead of waiting for a round trip.
|
||||
commands: Vec<String>,
|
||||
}
|
||||
|
||||
async fn bridge_info(State(app): State<AppState>) -> Json<BridgeInfo> {
|
||||
Json(BridgeInfo {
|
||||
name: "Yaak Bridge".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
capabilities: app.state.capabilities.clone(),
|
||||
commands: crate::rpc::implemented_commands(&app.router),
|
||||
})
|
||||
}
|
||||
|
||||
/// One envelope in, one out. Errors are carried inside the envelope, not as an
|
||||
/// HTTP status, so the browser host can reject the caller's promise with the
|
||||
/// backend's own message.
|
||||
async fn rpc_handler(
|
||||
State(app): State<AppState>,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> Json<RpcResponse> {
|
||||
let ctx = BridgeCtx { state: app.state.clone(), session: app.state.session.get() };
|
||||
log::debug!("RPC {}", req.cmd);
|
||||
let response = app.router.handle(req, &ctx).await;
|
||||
if let RpcResponse::Error { error, .. } = &response {
|
||||
log::warn!("RPC failed: {error}");
|
||||
}
|
||||
Json(response)
|
||||
}
|
||||
|
||||
async fn events_handler(State(app): State<AppState>, ws: WebSocketUpgrade) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_events_socket(socket, app))
|
||||
}
|
||||
|
||||
/// The tab's first frame reports who and where it is; everything after that is
|
||||
/// a reply to something the server asked.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AttachPayload {
|
||||
label: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
async fn handle_events_socket(socket: WebSocket, app: AppState) {
|
||||
use futures::{SinkExt, StreamExt};
|
||||
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
let mut outbound = app.state.events.subscribe();
|
||||
|
||||
// Server to client.
|
||||
let send_task = tokio::spawn(async move {
|
||||
loop {
|
||||
match outbound.recv().await {
|
||||
Ok(frame) => {
|
||||
let Ok(text) = serde_json::to_string(&frame) else {
|
||||
continue;
|
||||
};
|
||||
if sink.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A tab that fell behind has missed writes, and the model store
|
||||
// would be silently stale. Close instead, so a reconnect
|
||||
// re-reads the workspace from scratch.
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
log::warn!("Events client lagged by {n} frames; closing so it resyncs");
|
||||
break;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Client to server.
|
||||
let state = app.state.clone();
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(message)) = stream.next().await {
|
||||
let Message::Text(text) = message else {
|
||||
continue;
|
||||
};
|
||||
let Ok(frame) = serde_json::from_str::<EventFrame>(&text) else {
|
||||
log::warn!("Ignoring malformed event frame from browser");
|
||||
continue;
|
||||
};
|
||||
|
||||
// `bridge_attach` is the browser telling us what the desktop would
|
||||
// have read off the window: its label and its current URL.
|
||||
if frame.event == "bridge_attach" {
|
||||
match serde_json::from_value::<AttachPayload>(frame.payload.clone()) {
|
||||
Ok(attach) => {
|
||||
log::info!("Browser attached: {} at {}", attach.label, attach.url);
|
||||
state.session.set(SessionContext {
|
||||
label: attach.label,
|
||||
url: attach.url,
|
||||
});
|
||||
}
|
||||
Err(e) => log::warn!("Bad bridge_attach payload: {e}"),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
state.events.dispatch_inbound(frame);
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => {},
|
||||
_ = recv_task => {},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BodyQuery {
|
||||
/// Present so the shared token extractor doesn't reject the request; the
|
||||
/// value itself is checked in the middleware.
|
||||
#[allow(dead_code)]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// Stream a response body, with Range support.
|
||||
///
|
||||
/// Keyed by response id rather than by path: the tab hands back a `bodyPath`
|
||||
/// the backend gave it, and resolving that through the database means this
|
||||
/// route can only ever serve a file the engine wrote, not an arbitrary path a
|
||||
/// page asked for. Range matters because the video and audio viewers seek.
|
||||
async fn response_body(
|
||||
State(app): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Query(_q): Query<BodyQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let response = match app.state.db().get_http_response(&id) {
|
||||
Ok(response) => response,
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "No such response").into_response(),
|
||||
};
|
||||
|
||||
let Some(body_path) = response.body_path else {
|
||||
return (StatusCode::NOT_FOUND, "Response has no body").into_response();
|
||||
};
|
||||
|
||||
let mut file = match tokio::fs::File::open(&body_path).await {
|
||||
Ok(file) => file,
|
||||
Err(e) => return (StatusCode::NOT_FOUND, format!("Body unavailable: {e}")).into_response(),
|
||||
};
|
||||
|
||||
let total = match file.metadata().await {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body unreadable: {e}"))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let content_type = response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|h| h.value.clone())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()).and_then(parse_range);
|
||||
|
||||
let (start, end, status) = match range {
|
||||
Some((start, end)) => {
|
||||
let end = end.unwrap_or(total.saturating_sub(1)).min(total.saturating_sub(1));
|
||||
if total == 0 || start > end {
|
||||
return Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
(start, end, StatusCode::PARTIAL_CONTENT)
|
||||
}
|
||||
None => (0, total.saturating_sub(1), StatusCode::OK),
|
||||
};
|
||||
|
||||
let length = if total == 0 { 0 } else { end - start + 1 };
|
||||
|
||||
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to seek body").into_response();
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
if let Err(e) = file.read_exact(&mut buf).await {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read body: {e}"))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, length);
|
||||
|
||||
if status == StatusCode::PARTIAL_CONTENT {
|
||||
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
|
||||
}
|
||||
|
||||
builder.body(Body::from(buf)).unwrap()
|
||||
}
|
||||
|
||||
/// Parses a single `bytes=start-end` range. Multi-range requests are not
|
||||
/// answered as multipart; the first range is used, which browsers accept.
|
||||
fn parse_range(value: &str) -> Option<(u64, Option<u64>)> {
|
||||
let spec = value.strip_prefix("bytes=")?.split(',').next()?.trim();
|
||||
let (start, end) = spec.split_once('-')?;
|
||||
if start.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let start: u64 = start.parse().ok()?;
|
||||
let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_ranges() {
|
||||
assert_eq!(parse_range("bytes=0-499"), Some((0, Some(499))));
|
||||
assert_eq!(parse_range("bytes=500-"), Some((500, None)));
|
||||
assert_eq!(parse_range("bytes=0-99,200-299"), Some((0, Some(99))));
|
||||
// Suffix ranges ("last 500 bytes") aren't supported; callers get the
|
||||
// whole body, which is correct if wasteful.
|
||||
assert_eq!(parse_range("bytes=-500"), None);
|
||||
assert_eq!(parse_range("nonsense"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_comparison_requires_exact_match() {
|
||||
assert!(constant_time_eq("abc", "abc"));
|
||||
assert!(!constant_time_eq("abc", "abd"));
|
||||
assert!(!constant_time_eq("abc", "abcd"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user