mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-25 10:18:31 +02:00
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:
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
@@ -25,9 +20,7 @@ function App() {
|
||||
setBusy(true);
|
||||
setStatus("Starting...");
|
||||
try {
|
||||
const result = await invoke<ProxyStartResult>("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<boolean>("proxy_stop");
|
||||
const stopped = await rpc("proxy_stop", {});
|
||||
setPort(null);
|
||||
setStatus(stopped ? "Stopped" : "Not running");
|
||||
} catch (err) {
|
||||
|
||||
12
apps/yaak-proxy/rpc.ts
Normal file
12
apps/yaak-proxy/rpc.ts
Normal 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>>;
|
||||
}
|
||||
13
crates-proxy/yaak-proxy-lib/Cargo.toml
Normal file
13
crates-proxy/yaak-proxy-lib/Cargo.toml
Normal 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 }
|
||||
9
crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts
generated
Normal file
9
crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts
generated
Normal 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], };
|
||||
81
crates-proxy/yaak-proxy-lib/src/lib.rs
Normal file
81
crates-proxy/yaak-proxy-lib/src/lib.rs
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Option<ProxyHandle>>,
|
||||
}
|
||||
|
||||
#[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<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())
|
||||
fn rpc(
|
||||
router: State<'_, RpcRouter<ProxyCtx>>,
|
||||
ctx: State<'_, ProxyCtx>,
|
||||
cmd: String,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
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| {
|
||||
|
||||
12
crates/common/yaak-rpc/Cargo.toml
Normal file
12
crates/common/yaak-rpc/Cargo.toml
Normal 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 }
|
||||
159
crates/common/yaak-rpc/src/lib.rs
Normal file
159
crates/common/yaak-rpc/src/lib.rs
Normal 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)
|
||||
})
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user