-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: Generate Typescript functions for Rust commands #1514
Comments
I just want to mention Aleph-Alpha/ts-rs that does a great job on creating TS from Structs. Maybe something similar could be used in tauri. |
I think we could achive it with parse rust codes with AST (or something else) and generate TypeScript definition files like how |
Just for the inspiration, maybe tRPC architecture is a good place to start: https://trpc.io/ |
Any status update on this? Just curious where we stand rn |
@Cobular The team itself is currently not working on this (otherwise there would be at least some info here). With the upcoming IPC changes for v2 this is further delayed, at least on our side, i still think (or hope) that this could be tackled outside of tauri 🤔 |
I see! I’ll see if I have time!
…On Sep 26, 2022, 06:09 -0700, Fabian-Lars ***@***.***>, wrote:
@Cobular The team itself is currently not working on this (otherwise there would be at least some info here). With the upcoming IPC changes for v2 this is further delayed, at least on our side, i still think (or hope) that this could be tackled outside of tauri 🤔
—
Reply to this email directly, view it on GitHub, or unsubscribe.
You are receiving this because you were mentioned.Message ID: ***@***.***>
|
@FabianLars refers to the potential for community work around this. I have been working on rspc for the last little bit. rspc is designed to be an alternative to tRPC that works with a Rust backend and Typescript frontend. It creates completely end-to-end typesafe APIs and has a plugin so it can easily be used with Tauri. It also has optional React and SolidJS hooks on the frontend which use Tanstack Query under the hood. It is still very much a work-in-progress project and will likely have breaking changes in the future as I improve the API and introduce new features. Both Spacedrive (my employer) and Twidge have been using the rspc + Prisma Client Rust stack and it has been working pretty well. If Tauri were ever going to tackle this upstream they could have a look at Specta which is the type exporting system @Brendonovich and I made for rspc. Specta differs from ts-rs by its approach. Specta can take in a single type and recursively export all the types it depends on. With ts-rs there isn't an easy way to do that as they have designed the core around the assumption you will export each type individually with a dedicated derive macro. Specta also has a fairly language-agnostic core so it could potentially be used to export Rust types into various other languages although right now Typescript is the main focus. I have been messing around with supporting OpenAPI for example. |
@Brendonovich and I were talking about this and got to hacking. We managed to get typesafe Tauri commands working on top of Specta. The repository shows an example application that can export the command types to Typescript (and OpenAPI with a few caveats). The code can be found here. It's still a bit of a tech demo but it could be turned into a publishable library or even officially adopted by Tauri if they were interested. |
There's another crate to do the generation for this now courtesy of 1password - typeshare - crates.io: Rust Package Registry. If anyone else gets wroking on this beyond Oscar and Brendon's thing, could be helpful! |
Might be worth noting that I'm working on https://github.com/tauri-apps/tauri-bindgen now too, it has slightly different behavior and aims to the solutions brought up before, but it can generate typescript files for a given interface too so might be interesting |
That looks fantastic! This does indicate it's under heavy development, but how usable would you say this is today? I've just implemented build scripts to provide consistent types from my backend to the Tauri host so I'd I could add this to my project it'd be very helpful. |
@Cobular Typeshare is indeed interesting, but would it be applicable for Tauri commands given that they don't support function types? They also have no way to filter out Tauri-specific command arguments. I don't think static analysis would be powerful enough unless you used some sort of enum-based request and response model. As an aside, I've been working the past couple of days to make Specta more powerful than both ts-rs and Typeshare, and capable of exporting types on its own. Will be interesting to see which solutions the community prefers! |
You can use it with two big asterisks:
But: it would be tremendously helpful to have early testers, so by all means try it out please! I will be very responsive in the issues. Just make sure to pin the crates to specific commits so your code doesn't unexpectedly break |
I had the idea of specifying the types directly via the I imagine that the equivalent to OP's TS with this idea would be; type CustomRequest = {
message: String;
request: Number;
}
type CustomResponse = {
message: String;
other_val: Number;
}
type TauriCommands = {
my_first_custom_command: [
return: string,
args: { message: string },
],
my_second_custom_command: [
return: CustomResponse,
args: {
req: CustomRequest,
hello: string,
}
}
declare function invoke<T extends keyof TauriCommands>(cmd: T, args: T[1]): Promise<T[0]>; This adds 0 JavaScript overhead as there isn't a wrapping function call; you call the same I did make some breaking changes to the
To be non-breaking, the args thing is just a declare function invoke<T extends U[0], U extends keyof TauriCommands>(cmd: U, args: U[1]): Promise<T>; If we're voting on how generated types for commands are implemented in Tauri (I recognise that's not what we're doing), I vote types only. I would prefer to check back to my Rust files occasionally than to introduce boilerplate code and runtime overhead for the sake of a small bit of DevEx. I wouldn't be against |
I've gotten pretty far in doing this in my own project. For now I have a
#[cfg(test)]
mod test {
fn rust_type_to_ts(rust_type: &syn::Type) -> String {
match rust_type {
syn::Type::Path(type_path) if type_path.qself.is_none() => {
let ident = &type_path.path.segments.last().unwrap().ident;
match ident.to_string().as_str() {
"str" => "string".to_owned(),
"()" => "void".to_owned(),
"Result" => {
match &type_path.path.segments.last().unwrap().arguments {
syn::PathArguments::AngleBracketed(angle_bracketed_data) => {
let args: Vec<_> = angle_bracketed_data.args.iter().collect();
if let syn::GenericArgument::Type(ty) = args[0] {
rust_type_to_ts(ty)
} else {
panic!("Result without inner type")
}
},
_ => panic!("Unsupported angle type: {}", ident.to_string()),
}
},
"Vec" => {
match &type_path.path.segments.last().unwrap().arguments {
syn::PathArguments::AngleBracketed(angle_bracketed_data) => {
if let Some(syn::GenericArgument::Type(ty)) = angle_bracketed_data.args.first() {
format!("Array<{}>", rust_type_to_ts(ty))
} else {
panic!("Vec without inner type")
}
},
_ => panic!("Unsupported angle type: {}", ident.to_string()),
}
},
"HashMap" => {
match &type_path.path.segments.last().unwrap().arguments {
syn::PathArguments::AngleBracketed(angle_bracketed_data) => {
let args: Vec<_> = angle_bracketed_data.args.iter().collect();
if let syn::GenericArgument::Type(key_ty) = args[0] {
if let syn::GenericArgument::Type(value_ty) = args[1] {
format!("Record<{}, {}>", rust_type_to_ts(key_ty), rust_type_to_ts(value_ty))
} else {
panic!("HashMap without value type")
}
} else {
panic!("HashMap without key type")
}
},
_ => panic!("Unsupported angle type: {}", ident.to_string()),
}
},
_ => ident.to_string(),
}
},
syn::Type::Reference(type_reference) => {
if let syn::Type::Path(type_path) = *type_reference.elem.clone() {
let ident = &type_path.path.segments.last().unwrap().ident;
match ident.to_string().as_str() {
"str" => "string".to_owned(),
_ => panic!("Unsupported type: &{}", ident.to_string()),
}
} else {
panic!("Unsupported ref type: {}", quote::quote! {#type_reference}.to_string())
}
},
syn::Type::Tuple(tuple_type) if tuple_type.elems.is_empty() => {
"void".to_owned()
},
_ => panic!("Unsupported type: {}", quote::quote! {#rust_type}.to_string()),
}
}
#[test]
fn list_commands() {
let contents = std::fs::read_to_string("src/commands.rs").unwrap();
let ast = syn::parse_file(&contents).unwrap();
let mut commands = Vec::new();
for item in ast.items {
if let syn::Item::Fn(item_fn) = item {
let tauri_command_attr = item_fn.attrs.iter()
.find(|attr| {
attr.path().segments.iter().map(|seg| seg.ident.to_string()).collect::<Vec<_>>() == ["tauri", "command"]
});
if tauri_command_attr.is_some() {
let command_name = item_fn.sig.ident.to_string();
let mut arg_types = Vec::new();
for arg in &item_fn.sig.inputs {
if let syn::FnArg::Typed(pat_type) = arg {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
// Filter out State and AppHandle parameters
let ty_string = quote::quote! {#pat_type.ty}.to_string();
if !ty_string.contains("State") && !ty_string.contains("AppHandle") {
let ts_type = rust_type_to_ts(&pat_type.ty);
arg_types.push(format!("{}: {}", pat_ident.ident, ts_type));
}
}
}
}
let return_type = if let syn::ReturnType::Type(_, ty) = &item_fn.sig.output {
rust_type_to_ts(ty)
} else {
String::new()
};
let command_definition = format!(" {}: [\n return: {},\n args: {{ {} }}\n ]", command_name, return_type, arg_types.join(", "));
commands.push(command_definition);
}
}
}
let output = format!("type TauriCommands = {{\n{}\n}};", commands.join(",\n"));
println!("{}", output);
}
} I'm still using
#[derive(Debug, TS, Serialize, Deserialize, Clone)]
#[ts(export, export_to = "../src/lib/bindings/")]
pub struct ConversationMessagePayload {
#[ts(type="\"system\" | \"user\" | \"assistant\"")]
pub author: chatgpt::types::Role,
pub content: String,
}
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface ConversationMessagePayload { author: "system" | "user" | "assistant", content: string, } but now I'm noticing that I haven't made a
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Conversation {
pub id: uuid::Uuid,
pub history: Vec<ConversationEventRecord>,
} in this case it's fine since it's only for debugging
invoke("get_conversation", {
conversation_id: conversationId,
}).then((data: any) => {
console.log("got conversation debug info", data);
}); it just means that there's an expectation that every type used in the TauriCommands type I'm generating is either a ts built-in or is exported by So right now this output is using a type that doesn't exist on my frontend yet new_conversation: [
return: Conversation,
args: { }
], Right now my plan is to keep separate tl;dr
|
Got it working, but then I realized that tauri-specta and tauri-bindgen exist, guess I should read better next time 🤦♂️ Here's my updated thing for reference, it still doesn't add imports for the user types tho xD [dev-dependencies]
quote = "1.0.29"
syn = { version = "2.0.23", features = ["full"] }
indoc = "1.0.3" #[cfg(test)]
mod test {
fn rust_type_to_ts(rust_type: &syn::Type) -> String {
match rust_type {
syn::Type::Path(type_path) if type_path.qself.is_none() => {
let ident = &type_path.path.segments.last().unwrap().ident;
match ident.to_string().as_str() {
"str" => "string".to_owned(),
"String" => "string".to_owned(),
"()" => "void".to_owned(),
"Result" => match &type_path.path.segments.last().unwrap().arguments {
syn::PathArguments::AngleBracketed(angle_bracketed_data) => {
let args: Vec<_> = angle_bracketed_data.args.iter().collect();
if let syn::GenericArgument::Type(ty) = args[0] {
rust_type_to_ts(ty)
} else {
panic!("Result without inner type")
}
}
_ => panic!("Unsupported angle type: {}", ident.to_string()),
},
"Vec" => match &type_path.path.segments.last().unwrap().arguments {
syn::PathArguments::AngleBracketed(angle_bracketed_data) => {
if let Some(syn::GenericArgument::Type(ty)) =
angle_bracketed_data.args.first()
{
format!("Array<{}>", rust_type_to_ts(ty))
} else {
panic!("Vec without inner type")
}
}
_ => panic!("Unsupported angle type: {}", ident.to_string()),
},
"HashMap" => match &type_path.path.segments.last().unwrap().arguments {
syn::PathArguments::AngleBracketed(angle_bracketed_data) => {
let args: Vec<_> = angle_bracketed_data.args.iter().collect();
if let syn::GenericArgument::Type(key_ty) = args[0] {
if let syn::GenericArgument::Type(value_ty) = args[1] {
format!(
"Record<{}, {}>",
rust_type_to_ts(key_ty),
rust_type_to_ts(value_ty)
)
} else {
panic!("HashMap without value type")
}
} else {
panic!("HashMap without key type")
}
}
_ => panic!("Unsupported angle type: {}", ident.to_string()),
},
_ => ident.to_string(),
}
}
syn::Type::Reference(type_reference) => {
if let syn::Type::Path(type_path) = *type_reference.elem.clone() {
let ident = &type_path.path.segments.last().unwrap().ident;
match ident.to_string().as_str() {
"str" => "string".to_owned(),
_ => panic!("Unsupported type: &{}", ident.to_string()),
}
} else {
panic!(
"Unsupported ref type: {}",
quote::quote! {#type_reference}.to_string()
)
}
}
syn::Type::Tuple(tuple_type) if tuple_type.elems.is_empty() => "void".to_owned(),
_ => panic!(
"Unsupported type: {}",
quote::quote! {#rust_type}.to_string()
),
}
}
#[test]
fn build_command_type_definitions() {
let contents = std::fs::read_to_string("src/commands.rs").unwrap();
let ast = syn::parse_file(&contents).unwrap();
let mut commands = Vec::new();
for item in ast.items {
if let syn::Item::Fn(item_fn) = item {
let tauri_command_attr = item_fn.attrs.iter().find(|attr| {
attr.path()
.segments
.iter()
.map(|seg| seg.ident.to_string())
.collect::<Vec<_>>()
== ["tauri", "command"]
});
if tauri_command_attr.is_some() {
let command_name = item_fn.sig.ident.to_string();
let mut arg_types = Vec::new();
for arg in &item_fn.sig.inputs {
if let syn::FnArg::Typed(pat_type) = arg {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
// Filter out State and AppHandle parameters
let ty_string = quote::quote! {#pat_type.ty}.to_string();
if !ty_string.contains("State") && !ty_string.contains("AppHandle")
{
let ts_type = rust_type_to_ts(&pat_type.ty);
arg_types.push(format!("{}: {}", pat_ident.ident, ts_type));
}
}
}
}
let return_type = if let syn::ReturnType::Type(_, ty) = &item_fn.sig.output {
rust_type_to_ts(ty)
} else {
String::new()
};
let command_definition = format!(
" {}: {{\n returns: {},\n args: {{ {} }}\n }}",
command_name,
return_type,
arg_types.join(", ")
);
commands.push(command_definition);
}
}
}
// build file contents
let warning_header = "// THIS FILE IS AUTO-GENERATED BY CARGO TESTS! DO NOT EDIT!";
let invoke_import = "import { invoke as invokeRaw } from \"@tauri-apps/api\";";
let tauri_commands = format!("type TauriCommands = {{\n{}\n}};", commands.join(",\n"));
let invoke_fn = indoc::indoc! {"
export function invoke<T extends keyof TauriCommands>(cmd: T, args: TauriCommands[T][\"args\"]): Promise<TauriCommands[T][\"returns\"]> {
return invokeRaw(cmd, args);
}
"};
let output = format!(
"{}\n\n{}\n\n{}\n\n{}",
warning_header, invoke_import, tauri_commands, invoke_fn
);
// dump to file
std::fs::create_dir_all("../src/lib/bindings").unwrap();
let definitions_file =
std::fs::File::create("../src/lib/bindings/tauri_commands.d.ts").unwrap();
std::io::Write::write_all(
&mut std::io::BufWriter::new(definitions_file),
output.as_bytes(),
)
.unwrap();
}
} example output // THIS FILE IS AUTO-GENERATED BY CARGO TESTS! DO NOT EDIT!
import { invoke as invokeRaw } from "@tauri-apps/api";
type TauriCommands = {
list_conversation_titles: {
returns: Record<string, string>,
args: { }
},
get_conversation: {
returns: Conversation,
args: { conversation_id: string }
},
get_conversation_title: {
returns: string,
args: { conversation_id: string }
},
get_conversation_messages: {
returns: Array<ConversationMessagePayload>,
args: { conversation_id: string }
},
new_conversation: {
returns: Conversation,
args: { }
},
set_conversation_title: {
returns: void,
args: { conversation_id: string, new_title: string }
},
new_conversation_user_message: {
returns: void,
args: { conversation_id: string, content: string }
},
new_conversation_assistant_message: {
returns: void,
args: { conversation_id: string }
},
list_files: {
returns: Array<string>,
args: { }
}
};
export function invoke<T extends keyof TauriCommands>(cmd: T, args: TauriCommands[T]["args"]): Promise<TauriCommands[T]["returns"]> {
return invokeRaw(cmd, args);
} |
@TeamDman Was just about to recommend looking at On that note, I feel like this pretty much solved. While there's no officially recommended solution yet, there's plenty out there to generate TypeScript bindings to Tauri commands. Rust -> TypeScript
Schema -> Rust + TypeScript
Other Solutions
If there's any other good solutions I'm missing then please let me know! |
This comment was marked as spam.
This comment was marked as spam.
@michTheBrandofficial Please see Brendonovich's comment right above yours... |
Why is protocol buffers not mentioned here? |
Goal: Atomatically generate Typescript functions for Rust commands in Typescript environment
Why: Use Typescript with type checking for Rust commands rather than
invoke('some string', somedata)
How:
Assume these Rust commands are available
Using Typescript, we can create functions that conform to the Rust commands:
Now we can call
my_first_custom_command
andmy_second_custom_command
with type checking in Typescript.The steps can be:
tauri::commands
function in RustProblems that may arises:
Simple solution to the above solutions:
yarn tauri dev
generates Typescript files everytime a file insrc-tauri/src/tauri_commands
is modifiedIn discord chat, Denjell suggest that a WASM function can be generated from these Rust commands.
Originated from:
https://discord.com/channels/616186924390023171/731495047962558564/832483720728412192
The text was updated successfully, but these errors were encountered: