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 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-03-08 11:27:51 -07:00
parent 4c37e62146
commit 6f8c4c06bb
11 changed files with 332 additions and 67 deletions

26
Cargo.lock generated
View File

@@ -10306,11 +10306,12 @@ name = "yaak-app-proxy"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"log 0.4.29", "log 0.4.29",
"serde", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-os", "tauri-plugin-os",
"yaak-proxy", "yaak-proxy-lib",
"yaak-rpc",
"yaak-window", "yaak-window",
] ]
@@ -10590,6 +10591,17 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
] ]
[[package]]
name = "yaak-proxy-lib"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
"ts-rs",
"yaak-proxy",
"yaak-rpc",
]
[[package]] [[package]]
name = "yaak-proxy-models" name = "yaak-proxy-models"
version = "0.1.0" version = "0.1.0"
@@ -10607,6 +10619,16 @@ dependencies = [
"yaak-database", "yaak-database",
] ]
[[package]]
name = "yaak-rpc"
version = "0.0.0"
dependencies = [
"log 0.4.29",
"serde",
"serde_json",
"ts-rs",
]
[[package]] [[package]]
name = "yaak-sse" name = "yaak-sse"
version = "0.1.0" version = "0.1.0"

View File

@@ -4,6 +4,7 @@ members = [
"crates/yaak", "crates/yaak",
# Common/foundation crates # Common/foundation crates
"crates/common/yaak-database", "crates/common/yaak-database",
"crates/common/yaak-rpc",
# Shared crates (no Tauri dependency) # Shared crates (no Tauri dependency)
"crates/yaak-core", "crates/yaak-core",
"crates/yaak-common", "crates/yaak-common",
@@ -22,6 +23,7 @@ members = [
"crates/yaak-proxy", "crates/yaak-proxy",
# Proxy-specific crates # Proxy-specific crates
"crates-proxy/yaak-proxy-models", "crates-proxy/yaak-proxy-models",
"crates-proxy/yaak-proxy-lib",
# CLI crates # CLI crates
"crates-cli/yaak-cli", "crates-cli/yaak-cli",
# Tauri-specific crates # Tauri-specific crates
@@ -56,6 +58,7 @@ ts-rs = "11.1.0"
# Internal crates - common/foundation # Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" } yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
# Internal crates - shared # Internal crates - shared
yaak-core = { path = "crates/yaak-core" } yaak-core = { path = "crates/yaak-core" }
@@ -77,6 +80,7 @@ yaak-proxy = { path = "crates/yaak-proxy" }
# Internal crates - proxy # Internal crates - proxy
yaak-proxy-models = { path = "crates-proxy/yaak-proxy-models" } yaak-proxy-models = { path = "crates-proxy/yaak-proxy-models" }
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
# Internal crates - Tauri-specific # Internal crates - Tauri-specific
yaak-fonts = { path = "crates-tauri/yaak-fonts" } yaak-fonts = { path = "crates-tauri/yaak-fonts" }

View File

@@ -1,20 +1,15 @@
import "./main.css"; import "./main.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { type } from "@tauri-apps/plugin-os"; import { type } from "@tauri-apps/plugin-os";
import { Button, HeaderSize } from "@yaakapp-internal/ui"; import { Button, HeaderSize } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { useState } from "react"; import { useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { rpc } from "./rpc";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
type ProxyStartResult = {
port: number;
alreadyRunning: boolean;
};
function App() { function App() {
const [status, setStatus] = useState("Idle"); const [status, setStatus] = useState("Idle");
const [port, setPort] = useState<number | null>(null); const [port, setPort] = useState<number | null>(null);
@@ -25,9 +20,7 @@ function App() {
setBusy(true); setBusy(true);
setStatus("Starting..."); setStatus("Starting...");
try { try {
const result = await invoke<ProxyStartResult>("proxy_start", { const result = await rpc("proxy_start", { port: 9090 });
port: 9090,
});
setPort(result.port); setPort(result.port);
setStatus(result.alreadyRunning ? "Already running" : "Running"); setStatus(result.alreadyRunning ? "Already running" : "Running");
} catch (err) { } catch (err) {
@@ -41,7 +34,7 @@ function App() {
setBusy(true); setBusy(true);
setStatus("Stopping..."); setStatus("Stopping...");
try { try {
const stopped = await invoke<boolean>("proxy_stop"); const stopped = await rpc("proxy_stop", {});
setPort(null); setPort(null);
setStatus(stopped ? "Stopped" : "Not running"); setStatus(stopped ? "Stopped" : "Not running");
} catch (err) { } catch (err) {

12
apps/yaak-proxy/rpc.ts Normal file
View File

@@ -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<K extends keyof RpcSchema> = RpcSchema[K][0];
type Res<K extends keyof RpcSchema> = RpcSchema[K][1];
export async function rpc<K extends keyof RpcSchema>(
cmd: K,
payload: Req<K>,
): Promise<Res<K>> {
return invoke("rpc", { cmd, payload }) as Promise<Res<K>>;
}

View File

@@ -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 }

View File

@@ -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<string, never>;
export type RpcSchema = { proxy_start: [ProxyStartRequest, ProxyStartResponse], proxy_stop: [ProxyStopRequest, boolean], };

View File

@@ -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<Option<ProxyHandle>>,
}
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<u16>,
}
#[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<ProxyStartResponse, RpcError> {
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<bool, RpcError> {
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,
}

View File

@@ -14,8 +14,9 @@ tauri-build = { version = "2.5.3", features = [] }
[dependencies] [dependencies]
log = { workspace = true } log = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true }
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-os = "2.3.2" tauri-plugin-os = "2.3.2"
yaak-proxy = { workspace = true } yaak-proxy-lib = { workspace = true }
yaak-rpc = { workspace = true }
yaak-window = { workspace = true } yaak-window = { workspace = true }

View File

@@ -1,66 +1,25 @@
use log::error; use log::error;
use serde::Serialize;
use std::sync::Mutex;
use tauri::{RunEvent, State}; use tauri::{RunEvent, State};
use yaak_proxy::ProxyHandle; use yaak_proxy_lib::ProxyCtx;
use yaak_rpc::RpcRouter;
use yaak_window::window::CreateWindowConfig; use yaak_window::window::CreateWindowConfig;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyMetadata {
name: String,
version: String,
}
#[derive(Default)]
struct ProxyState {
handle: Mutex<Option<ProxyHandle>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyStartResult {
port: u16,
already_running: bool,
}
#[tauri::command] #[tauri::command]
fn proxy_metadata(app_handle: tauri::AppHandle) -> ProxyMetadata { fn rpc(
ProxyMetadata { router: State<'_, RpcRouter<ProxyCtx>>,
name: app_handle.package_info().name.clone(), ctx: State<'_, ProxyCtx>,
version: app_handle.package_info().version.to_string(), cmd: String,
} payload: serde_json::Value,
} ) -> Result<serde_json::Value, String> {
router.dispatch(&cmd, payload, &ctx).map_err(|e| e.message)
#[tauri::command]
fn proxy_start(
state: State<'_, ProxyState>,
port: Option<u16>,
) -> Result<ProxyStartResult, String> {
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<bool, String> {
let mut handle = state.handle.lock().map_err(|_| "failed to lock proxy state".to_string())?;
Ok(handle.take().is_some())
} }
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.manage(ProxyState::default()) .manage(ProxyCtx::new())
.invoke_handler(tauri::generate_handler![proxy_metadata, proxy_start, proxy_stop]) .manage(yaak_proxy_lib::build_router())
.invoke_handler(tauri::generate_handler![rpc])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building yaak proxy tauri application") .expect("error while building yaak proxy tauri application")
.run(|app_handle, event| { .run(|app_handle, event| {

View File

@@ -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 }

View File

@@ -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<Ctx> = Box<dyn Fn(&Ctx, serde_json::Value) -> Result<serde_json::Value, RpcError> + 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<serde_json::Error> 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<Ctx> {
handlers: HashMap<&'static str, HandlerFn<Ctx>>,
}
impl<Ctx> RpcRouter<Ctx> {
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<Ctx>) {
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<serde_json::Value, RpcError> {
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<Res, RpcError>`
/// where `Req: DeserializeOwned` and `Res: Serialize`.
///
/// # Example
/// ```ignore
/// fn proxy_start(ctx: &MyCtx, req: StartReq) -> Result<StartRes, RpcError> { ... }
///
/// 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)
})
};
}