From 6f8c4c06bb9f7c0db307bca24fe54e6f6e20e54e Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 8 Mar 2026 11:27:51 -0700 Subject: [PATCH] Add transport-agnostic RPC layer for proxy app Introduces yaak-rpc (shared RPC infrastructure) and yaak-proxy-lib (proxy app logic decoupled from any frontend). A single Tauri command dispatches all RPC calls through a typed router, with TypeScript types auto-generated via ts-rs and a generic rpc() function for type-safe calls from the frontend. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 26 ++- Cargo.toml | 4 + apps/yaak-proxy/main.tsx | 13 +- apps/yaak-proxy/rpc.ts | 12 ++ crates-proxy/yaak-proxy-lib/Cargo.toml | 13 ++ .../yaak-proxy-lib/bindings/gen_rpc.ts | 9 + crates-proxy/yaak-proxy-lib/src/lib.rs | 81 +++++++++ crates-tauri/yaak-app-proxy/Cargo.toml | 5 +- crates-tauri/yaak-app-proxy/src/lib.rs | 65 ++----- crates/common/yaak-rpc/Cargo.toml | 12 ++ crates/common/yaak-rpc/src/lib.rs | 159 ++++++++++++++++++ 11 files changed, 332 insertions(+), 67 deletions(-) create mode 100644 apps/yaak-proxy/rpc.ts create mode 100644 crates-proxy/yaak-proxy-lib/Cargo.toml create mode 100644 crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts create mode 100644 crates-proxy/yaak-proxy-lib/src/lib.rs create mode 100644 crates/common/yaak-rpc/Cargo.toml create mode 100644 crates/common/yaak-rpc/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 30cecb2c..8f863d87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10306,11 +10306,12 @@ name = "yaak-app-proxy" version = "0.0.0" dependencies = [ "log 0.4.29", - "serde", + "serde_json", "tauri", "tauri-build", "tauri-plugin-os", - "yaak-proxy", + "yaak-proxy-lib", + "yaak-rpc", "yaak-window", ] @@ -10590,6 +10591,17 @@ dependencies = [ "tokio-rustls", ] +[[package]] +name = "yaak-proxy-lib" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "ts-rs", + "yaak-proxy", + "yaak-rpc", +] + [[package]] name = "yaak-proxy-models" version = "0.1.0" @@ -10607,6 +10619,16 @@ dependencies = [ "yaak-database", ] +[[package]] +name = "yaak-rpc" +version = "0.0.0" +dependencies = [ + "log 0.4.29", + "serde", + "serde_json", + "ts-rs", +] + [[package]] name = "yaak-sse" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 13b69c00..19990e8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/yaak", # Common/foundation crates "crates/common/yaak-database", + "crates/common/yaak-rpc", # Shared crates (no Tauri dependency) "crates/yaak-core", "crates/yaak-common", @@ -22,6 +23,7 @@ members = [ "crates/yaak-proxy", # Proxy-specific crates "crates-proxy/yaak-proxy-models", + "crates-proxy/yaak-proxy-lib", # CLI crates "crates-cli/yaak-cli", # Tauri-specific crates @@ -56,6 +58,7 @@ ts-rs = "11.1.0" # Internal crates - common/foundation yaak-database = { path = "crates/common/yaak-database" } +yaak-rpc = { path = "crates/common/yaak-rpc" } # Internal crates - shared yaak-core = { path = "crates/yaak-core" } @@ -77,6 +80,7 @@ yaak-proxy = { path = "crates/yaak-proxy" } # Internal crates - proxy yaak-proxy-models = { path = "crates-proxy/yaak-proxy-models" } +yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" } # Internal crates - Tauri-specific yaak-fonts = { path = "crates-tauri/yaak-fonts" } diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index bd3679fd..1755f27c 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -1,20 +1,15 @@ import "./main.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { invoke } from "@tauri-apps/api/core"; import { type } from "@tauri-apps/plugin-os"; import { Button, HeaderSize } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { StrictMode } from "react"; import { useState } from "react"; import { createRoot } from "react-dom/client"; +import { rpc } from "./rpc"; const queryClient = new QueryClient(); -type ProxyStartResult = { - port: number; - alreadyRunning: boolean; -}; - function App() { const [status, setStatus] = useState("Idle"); const [port, setPort] = useState(null); @@ -25,9 +20,7 @@ function App() { setBusy(true); setStatus("Starting..."); try { - const result = await invoke("proxy_start", { - port: 9090, - }); + const result = await rpc("proxy_start", { port: 9090 }); setPort(result.port); setStatus(result.alreadyRunning ? "Already running" : "Running"); } catch (err) { @@ -41,7 +34,7 @@ function App() { setBusy(true); setStatus("Stopping..."); try { - const stopped = await invoke("proxy_stop"); + const stopped = await rpc("proxy_stop", {}); setPort(null); setStatus(stopped ? "Stopped" : "Not running"); } catch (err) { diff --git a/apps/yaak-proxy/rpc.ts b/apps/yaak-proxy/rpc.ts new file mode 100644 index 00000000..0597ce44 --- /dev/null +++ b/apps/yaak-proxy/rpc.ts @@ -0,0 +1,12 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { RpcSchema } from "../../crates-proxy/yaak-proxy-lib/bindings/gen_rpc"; + +type Req = RpcSchema[K][0]; +type Res = RpcSchema[K][1]; + +export async function rpc( + cmd: K, + payload: Req, +): Promise> { + return invoke("rpc", { cmd, payload }) as Promise>; +} diff --git a/crates-proxy/yaak-proxy-lib/Cargo.toml b/crates-proxy/yaak-proxy-lib/Cargo.toml new file mode 100644 index 00000000..c1c8a762 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "yaak-proxy-lib" +version = "0.0.0" +edition = "2024" +authors = ["Gregory Schier"] +publish = false + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +ts-rs = { workspace = true } +yaak-proxy = { workspace = true } +yaak-rpc = { workspace = true } diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts new file mode 100644 index 00000000..37e74a3a --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProxyStartRequest = { port: number | null, }; + +export type ProxyStartResponse = { port: number, alreadyRunning: boolean, }; + +export type ProxyStopRequest = Record; + +export type RpcSchema = { proxy_start: [ProxyStartRequest, ProxyStartResponse], proxy_stop: [ProxyStopRequest, boolean], }; diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs new file mode 100644 index 00000000..a218db85 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -0,0 +1,81 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; +use ts_rs::TS; +use yaak_proxy::ProxyHandle; +use yaak_rpc::{RpcError, define_rpc}; + +// -- Context shared across all RPC handlers -- + +pub struct ProxyCtx { + handle: Mutex>, +} + +impl ProxyCtx { + pub fn new() -> Self { + Self { + handle: Mutex::new(None), + } + } +} + +// -- Request/response types -- + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ProxyStartRequest { + pub port: Option, +} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +#[serde(rename_all = "camelCase")] +pub struct ProxyStartResponse { + pub port: u16, + pub already_running: bool, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ProxyStopRequest {} + +// -- Handlers -- + +fn proxy_start(ctx: &ProxyCtx, req: ProxyStartRequest) -> Result { + let mut handle = ctx + .handle + .lock() + .map_err(|_| RpcError { message: "lock poisoned".into() })?; + + if let Some(existing) = handle.as_ref() { + return Ok(ProxyStartResponse { + port: existing.port, + already_running: true, + }); + } + + let proxy_handle = yaak_proxy::start_proxy(req.port.unwrap_or(0)) + .map_err(|e| RpcError { message: e })?; + let port = proxy_handle.port; + *handle = Some(proxy_handle); + + Ok(ProxyStartResponse { + port, + already_running: false, + }) +} + +fn proxy_stop(ctx: &ProxyCtx, _req: ProxyStopRequest) -> Result { + let mut handle = ctx + .handle + .lock() + .map_err(|_| RpcError { message: "lock poisoned".into() })?; + Ok(handle.take().is_some()) +} + +// -- Router + Schema -- + +define_rpc! { + ProxyCtx; + "proxy_start" => proxy_start(ProxyStartRequest) -> ProxyStartResponse, + "proxy_stop" => proxy_stop(ProxyStopRequest) -> bool, +} diff --git a/crates-tauri/yaak-app-proxy/Cargo.toml b/crates-tauri/yaak-app-proxy/Cargo.toml index 5a5be04c..71ee2751 100644 --- a/crates-tauri/yaak-app-proxy/Cargo.toml +++ b/crates-tauri/yaak-app-proxy/Cargo.toml @@ -14,8 +14,9 @@ tauri-build = { version = "2.5.3", features = [] } [dependencies] log = { workspace = true } -serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } tauri = { workspace = true } tauri-plugin-os = "2.3.2" -yaak-proxy = { workspace = true } +yaak-proxy-lib = { workspace = true } +yaak-rpc = { workspace = true } yaak-window = { workspace = true } diff --git a/crates-tauri/yaak-app-proxy/src/lib.rs b/crates-tauri/yaak-app-proxy/src/lib.rs index 622c967c..d0b6e3a5 100644 --- a/crates-tauri/yaak-app-proxy/src/lib.rs +++ b/crates-tauri/yaak-app-proxy/src/lib.rs @@ -1,66 +1,25 @@ use log::error; -use serde::Serialize; -use std::sync::Mutex; use tauri::{RunEvent, State}; -use yaak_proxy::ProxyHandle; +use yaak_proxy_lib::ProxyCtx; +use yaak_rpc::RpcRouter; use yaak_window::window::CreateWindowConfig; -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ProxyMetadata { - name: String, - version: String, -} - -#[derive(Default)] -struct ProxyState { - handle: Mutex>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ProxyStartResult { - port: u16, - already_running: bool, -} - #[tauri::command] -fn proxy_metadata(app_handle: tauri::AppHandle) -> ProxyMetadata { - ProxyMetadata { - name: app_handle.package_info().name.clone(), - version: app_handle.package_info().version.to_string(), - } -} - -#[tauri::command] -fn proxy_start( - state: State<'_, ProxyState>, - port: Option, -) -> Result { - let mut handle = state.handle.lock().map_err(|_| "failed to lock proxy state".to_string())?; - - if let Some(existing) = handle.as_ref() { - return Ok(ProxyStartResult { port: existing.port, already_running: true }); - } - - let proxy_handle = yaak_proxy::start_proxy(port.unwrap_or(0))?; - let started_port = proxy_handle.port; - *handle = Some(proxy_handle); - - Ok(ProxyStartResult { port: started_port, already_running: false }) -} - -#[tauri::command] -fn proxy_stop(state: State<'_, ProxyState>) -> Result { - let mut handle = state.handle.lock().map_err(|_| "failed to lock proxy state".to_string())?; - Ok(handle.take().is_some()) +fn rpc( + router: State<'_, RpcRouter>, + ctx: State<'_, ProxyCtx>, + cmd: String, + payload: serde_json::Value, +) -> Result { + router.dispatch(&cmd, payload, &ctx).map_err(|e| e.message) } pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_os::init()) - .manage(ProxyState::default()) - .invoke_handler(tauri::generate_handler![proxy_metadata, proxy_start, proxy_stop]) + .manage(ProxyCtx::new()) + .manage(yaak_proxy_lib::build_router()) + .invoke_handler(tauri::generate_handler![rpc]) .build(tauri::generate_context!()) .expect("error while building yaak proxy tauri application") .run(|app_handle, event| { diff --git a/crates/common/yaak-rpc/Cargo.toml b/crates/common/yaak-rpc/Cargo.toml new file mode 100644 index 00000000..dfa0214d --- /dev/null +++ b/crates/common/yaak-rpc/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "yaak-rpc" +version = "0.0.0" +edition = "2024" +authors = ["Gregory Schier"] +publish = false + +[dependencies] +log = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +ts-rs = { workspace = true } diff --git a/crates/common/yaak-rpc/src/lib.rs b/crates/common/yaak-rpc/src/lib.rs new file mode 100644 index 00000000..60837c48 --- /dev/null +++ b/crates/common/yaak-rpc/src/lib.rs @@ -0,0 +1,159 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Type-erased handler function: takes context + JSON payload, returns JSON or error. +type HandlerFn = Box Result + Send + Sync>; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcError { + pub message: String, +} + +impl std::fmt::Display for RpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for RpcError {} + +impl From for RpcError { + fn from(e: serde_json::Error) -> Self { + Self { message: e.to_string() } + } +} + +/// Incoming message from a client (Tauri invoke, WebSocket, etc.). +#[derive(Debug, Deserialize)] +pub struct RpcRequest { + pub id: String, + pub cmd: String, + #[serde(default)] + pub payload: serde_json::Value, +} + +/// Outgoing response to a client. +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +pub enum RpcResponse { + Success { + id: String, + payload: serde_json::Value, + }, + Error { + id: String, + error: String, + }, +} + +/// Transport-agnostic command router. +/// +/// Register typed handler functions, then dispatch incoming JSON messages. +/// Each transport adapter (Tauri, WebSocket, etc.) calls `dispatch()`. +pub struct RpcRouter { + handlers: HashMap<&'static str, HandlerFn>, +} + +impl RpcRouter { + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + + /// Register a handler for a command name. + /// Use the `rpc_handler!` macro to wrap a typed function. + pub fn register(&mut self, cmd: &'static str, handler: HandlerFn) { + self.handlers.insert(cmd, handler); + } + + /// Dispatch a command by name with a JSON payload. + pub fn dispatch( + &self, + cmd: &str, + payload: serde_json::Value, + ctx: &Ctx, + ) -> Result { + match self.handlers.get(cmd) { + Some(handler) => handler(ctx, payload), + None => Err(RpcError { + message: format!("unknown command: {cmd}"), + }), + } + } + + /// Handle a full `RpcRequest`, returning an `RpcResponse`. + pub fn handle(&self, req: RpcRequest, ctx: &Ctx) -> RpcResponse { + match self.dispatch(&req.cmd, req.payload, ctx) { + Ok(payload) => RpcResponse::Success { + id: req.id, + payload, + }, + Err(e) => RpcResponse::Error { + id: req.id, + error: e.message, + }, + } + } + + pub fn commands(&self) -> Vec<&'static str> { + self.handlers.keys().copied().collect() + } +} + +/// Define RPC commands with a single source of truth. +/// +/// Generates: +/// - `build_router()` — creates an `RpcRouter` with all handlers registered +/// - `RpcSchema` — a struct with ts-rs derives for TypeScript type generation +/// +/// # Example +/// ```ignore +/// define_rpc! { +/// ProxyCtx; +/// "proxy_start" => proxy_start(ProxyStartRequest) -> ProxyStartResponse, +/// "proxy_stop" => proxy_stop(ProxyStopRequest) -> bool, +/// } +/// ``` +#[macro_export] +macro_rules! define_rpc { + ( + $ctx:ty; + $( $name:literal => $handler:ident ( $req:ty ) -> $res:ty ),* $(,)? + ) => { + pub fn build_router() -> $crate::RpcRouter<$ctx> { + let mut router = $crate::RpcRouter::new(); + $( router.register($name, $crate::rpc_handler!($handler)); )* + router + } + + #[derive(ts_rs::TS)] + #[ts(export, export_to = "gen_rpc.ts")] + pub struct RpcSchema { + $( pub $handler: ($req, $res), )* + } + }; +} + +/// Wrap a typed handler function into a type-erased `HandlerFn`. +/// +/// The function must have the signature: +/// `fn(ctx: &Ctx, req: Req) -> Result` +/// where `Req: DeserializeOwned` and `Res: Serialize`. +/// +/// # Example +/// ```ignore +/// fn proxy_start(ctx: &MyCtx, req: StartReq) -> Result { ... } +/// +/// router.register("proxy_start", rpc_handler!(proxy_start)); +/// ``` +#[macro_export] +macro_rules! rpc_handler { + ($f:expr) => { + Box::new(|ctx, payload| { + let req = serde_json::from_value(payload).map_err($crate::RpcError::from)?; + let res = $f(ctx, req)?; + serde_json::to_value(res).map_err($crate::RpcError::from) + }) + }; +}