diff --git a/Cargo.toml b/Cargo.toml index 66f991f8e..9c70c6683 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "leptos_macro", "leptos_reactive", "leptos_server", + "server_fn", # integrations "integrations/actix", @@ -29,6 +30,8 @@ leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.0 leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.0" } leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.0" } leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.0" } +server_fn = { path = "./server_fn", default-features = false, version = "0.2.0" } +server_fn_macro = { path = "./server_fn_macro", default-features = false, version = "0.2.0" } leptos_config = { path = "./leptos_config", default-features = false, version = "0.2.0" } leptos_router = { path = "./router", version = "0.2.0" } leptos_meta = { path = "./meta", default-feature = false, version = "0.2.0" } diff --git a/leptos/Cargo.toml b/leptos/Cargo.toml index e45c181a4..b57ae49b5 100644 --- a/leptos/Cargo.toml +++ b/leptos/Cargo.toml @@ -17,6 +17,7 @@ leptos_server = { workspace = true } leptos_config = { workspace = true } tracing = "0.1" typed-builder = "0.12" +server_fn = { workspace = true } [dev-dependencies] leptos = { path = ".", default-features = false } diff --git a/leptos/src/lib.rs b/leptos/src/lib.rs index 3f33642b6..c3d6cd777 100644 --- a/leptos/src/lib.rs +++ b/leptos/src/lib.rs @@ -168,6 +168,7 @@ pub use leptos_server::{ self, create_action, create_multi_action, create_server_action, create_server_multi_action, Action, MultiAction, ServerFn, ServerFnError, }; +pub use server_fn::{self, ServerFn as _}; pub use typed_builder; mod error_boundary; pub use error_boundary::*; diff --git a/leptos_macro/Cargo.toml b/leptos_macro/Cargo.toml index 2f41a720c..f2dcc0f1c 100644 --- a/leptos_macro/Cargo.toml +++ b/leptos_macro/Cargo.toml @@ -24,6 +24,7 @@ syn-rsx = "0.9" leptos_dom = { workspace = true } leptos_reactive = { workspace = true } leptos_server = { workspace = true } +server_fn_macro = { workspace = true } convert_case = "0.6.0" uuid = { version = "1", features = ["v4"] } diff --git a/leptos_macro/src/lib.rs b/leptos_macro/src/lib.rs index f8e6936d0..b2a0d4dae 100644 --- a/leptos_macro/src/lib.rs +++ b/leptos_macro/src/lib.rs @@ -7,7 +7,7 @@ extern crate proc_macro_error; use proc_macro::TokenStream; use proc_macro2::TokenTree; use quote::ToTokens; -use server::server_macro_impl; +use server_fn_macro::{server_macro_impl, ServerContext}; use syn::parse_macro_input; use syn_rsx::{parse, NodeAttribute, NodeElement}; @@ -35,7 +35,6 @@ mod view; use template::render_template; use view::render_view; mod component; -mod server; mod template; /// The `view` macro uses RSX (like JSX, but Rust!) It follows most of the @@ -678,7 +677,16 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream { /// or response or other server-only dependencies, but it does *not* have access to reactive state that exists in the client. #[proc_macro_attribute] pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream { - match server_macro_impl(args, s.into()) { + let context = ServerContext { + ty: syn::parse_quote!(Scope), + path: syn::parse_quote!(::leptos::Scope), + }; + match server_macro_impl( + args.into(), + s.into(), + Some(context), + Some(syn::parse_quote!(::leptos::server_fn)), + ) { Err(e) => e.to_compile_error().into(), Ok(s) => s.to_token_stream().into(), } diff --git a/leptos_server/Cargo.toml b/leptos_server/Cargo.toml index d4d0af365..aee0c15a8 100644 --- a/leptos_server/Cargo.toml +++ b/leptos_server/Cargo.toml @@ -11,6 +11,7 @@ readme = "../README.md" [dependencies] leptos_dom = { workspace = true } leptos_reactive = { workspace = true } +server_fn = { workspace = true } form_urlencoded = "1" gloo-net = "0.2" js-sys = "0.3" @@ -41,6 +42,7 @@ hydrate = [ ssr = [ #"leptos/ssr", "leptos_reactive/ssr", + "server_fn/ssr", ] stable = [ #"leptos/stable", diff --git a/leptos_server/src/lib.rs b/leptos_server/src/lib.rs index 049ae30e8..5f998d1fd 100644 --- a/leptos_server/src/lib.rs +++ b/leptos_server/src/lib.rs @@ -79,15 +79,7 @@ //! or response or other server-only dependencies, but it does *not* have access to reactive state that exists in the client. use leptos_reactive::*; -use proc_macro2::{Literal, TokenStream}; -use quote::TokenStreamExt; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use std::{future::Future, pin::Pin, str::FromStr}; -use syn::{ - parse::{Parse, ParseStream}, - parse_quote, -}; -use thiserror::Error; +pub use server_fn::{Encoding, Payload, ServerFnError}; mod action; mod multi_action; @@ -100,27 +92,77 @@ use std::{ }; #[cfg(any(feature = "ssr", doc))] -type ServerFnTraitObj = dyn Fn( - Scope, - &[u8], - ) -> Pin>>> - + Send - + Sync; +type ServerFnTraitObj = server_fn::ServerFnTraitObj; #[cfg(any(feature = "ssr", doc))] lazy_static::lazy_static! { static ref REGISTERED_SERVER_FUNCTIONS: Arc>>> = Default::default(); } -/// A dual type to hold the possible Response datatypes -#[derive(Debug)] -pub enum Payload { - ///Encodes Data using CBOR - Binary(Vec), - ///Encodes data in the URL - Url(String), - ///Encodes Data using Json - Json(String), +#[cfg(any(feature = "ssr", doc))] +/// The registry of all Leptos server functions. +pub struct LeptosServerFnRegistry; + +#[cfg(any(feature = "ssr"))] +impl server_fn::ServerFunctionRegistry for LeptosServerFnRegistry { + type Error = ServerRegistrationFnError; + + fn register( + url: &'static str, + server_function: Arc, + ) -> Result<(), Self::Error> { + // store it in the hashmap + let mut write = REGISTERED_SERVER_FUNCTIONS + .write() + .map_err(|e| ServerRegistrationFnError::Poisoned(e.to_string()))?; + let prev = write.insert(url, server_function); + + // if there was already a server function with this key, + // return Err + match prev { + Some(_) => { + Err(ServerRegistrationFnError::AlreadyRegistered(format!( + "There was already a server function registered at {:?}. \ + This can happen if you use the same server function name \ + in two different modules + on `stable` or in `release` mode.", + url + ))) + } + None => Ok(()), + } + } + + /// Returns the server function registered at the given URL, or `None` if no function is registered at that URL. + fn get(url: &str) -> Option> { + REGISTERED_SERVER_FUNCTIONS + .read() + .ok() + .and_then(|fns| fns.get(url).cloned()) + } + + /// Returns a list of all registered server functions. + fn paths_registered() -> Vec<&'static str> { + REGISTERED_SERVER_FUNCTIONS + .read() + .ok() + .map(|fns| fns.keys().cloned().collect()) + .unwrap_or_default() + } +} + +#[cfg(any(feature = "ssr", doc))] +/// Errors that can occur when registering a server function. +#[derive( + thiserror::Error, Debug, Clone, serde::Serialize, serde::Deserialize, +)] +pub enum ServerRegistrationFnError { + /// The server function is already registered. + #[error("The server function {0} is already registered")] + AlreadyRegistered(String), + /// The server function registry is poisoned. + #[error("The server function registry is poisoned: {0}")] + Poisoned(String), } /// Attempts to find a server function registered at the given path. @@ -168,67 +210,13 @@ pub enum Payload { /// ``` #[cfg(any(feature = "ssr", doc))] pub fn server_fn_by_path(path: &str) -> Option> { - REGISTERED_SERVER_FUNCTIONS - .read() - .ok() - .and_then(|fns| fns.get(path).cloned()) + server_fn::server_fn_by_path::(path) } /// Returns the set of currently-registered server function paths, for debugging purposes. #[cfg(any(feature = "ssr", doc))] pub fn server_fns_by_path() -> Vec<&'static str> { - REGISTERED_SERVER_FUNCTIONS - .read() - .map(|vals| vals.keys().copied().collect()) - .unwrap_or_default() -} - -/// Holds the current options for encoding types. -/// More could be added, but they need to be serde -#[derive(Debug, PartialEq)] -pub enum Encoding { - /// A Binary Encoding Scheme Called Cbor - Cbor, - /// The Default URL-encoded encoding method - Url, -} - -impl FromStr for Encoding { - type Err = (); - - fn from_str(input: &str) -> Result { - match input { - "URL" => Ok(Encoding::Url), - "Cbor" => Ok(Encoding::Cbor), - _ => Err(()), - } - } -} - -impl quote::ToTokens for Encoding { - fn to_tokens(&self, tokens: &mut TokenStream) { - let option: syn::Ident = match *self { - Encoding::Cbor => parse_quote!(Cbor), - Encoding::Url => parse_quote!(Url), - }; - let expansion: syn::Ident = syn::parse_quote! { - Encoding::#option - }; - tokens.append(expansion); - } -} - -impl Parse for Encoding { - fn parse(input: ParseStream) -> syn::Result { - let variant_name: String = input.parse::()?.to_string(); - - // Need doubled quotes because variant_name doubles it - match variant_name.as_ref() { - "\"Url\"" => Ok(Self::Url), - "\"Cbor\"" => Ok(Self::Cbor), - _ => panic!("Encoding Not Found"), - } - } + server_fn::server_fns_by_path::() } /// Defines a "server function." A server function can be called from the server or the client, @@ -243,220 +231,12 @@ impl Parse for Encoding { /// can be queried on the server for routing purposes by calling [server_fn_by_path]. /// /// Technically, the trait is implemented on a type that describes the server function's arguments. -pub trait ServerFn -where - Self: Serialize + DeserializeOwned + Sized + 'static, -{ - /// The return type of the function. - type Output: Serialize; - - /// URL prefix that should be prepended by the client to the generated URL. - fn prefix() -> &'static str; - - /// The path at which the server function can be reached on the server. - fn url() -> &'static str; - - /// The path at which the server function can be reached on the server. - fn encoding() -> Encoding; - - /// Runs the function on the server. - #[cfg(any(feature = "ssr", doc))] - fn call_fn( - self, - cx: Scope, - ) -> Pin>>>; - - /// Runs the function on the client by sending an HTTP request to the server. - #[cfg(any(not(feature = "ssr"), doc))] - fn call_fn_client( - self, - cx: Scope, - ) -> Pin>>>; - +pub trait ServerFn: server_fn::ServerFn { /// Registers the server function, allowing the server to query it by URL. #[cfg(any(feature = "ssr", doc))] fn register() -> Result<(), ServerFnError> { - // create the handler for this server function - // takes a String -> returns its async value - - let run_server_fn = Arc::new(|cx: Scope, data: &[u8]| { - // decode the args - let value = match Self::encoding() { - Encoding::Url => serde_urlencoded::from_bytes(data) - .map_err(|e| ServerFnError::Deserialization(e.to_string())), - Encoding::Cbor => ciborium::de::from_reader(data) - .map_err(|e| ServerFnError::Deserialization(e.to_string())), - }; - Box::pin(async move { - let value: Self = match value { - Ok(v) => v, - Err(e) => return Err(e), - }; - - // call the function - let result = match value.call_fn(cx).await { - Ok(r) => r, - Err(e) => return Err(e), - }; - - // serialize the output - let result = match Self::encoding() { - Encoding::Url => match serde_json::to_string(&result) - .map_err(|e| { - ServerFnError::Serialization(e.to_string()) - }) { - Ok(r) => Payload::Url(r), - Err(e) => return Err(e), - }, - Encoding::Cbor => { - let mut buffer: Vec = Vec::new(); - match ciborium::ser::into_writer(&result, &mut buffer) - .map_err(|e| { - ServerFnError::Serialization(e.to_string()) - }) { - Ok(_) => Payload::Binary(buffer), - Err(e) => return Err(e), - } - } - }; - - Ok(result) - }) - as Pin>>> - }); - - // store it in the hashmap - let mut write = REGISTERED_SERVER_FUNCTIONS - .write() - .map_err(|e| ServerFnError::Registration(e.to_string()))?; - let prev = write.insert(Self::url(), run_server_fn); - - // if there was already a server function with this key, - // return Err - match prev { - Some(_) => Err(ServerFnError::Registration(format!( - "There was already a server function registered at {:?}. This \ - can happen if you use the same server function name in two \ - different modules - on `stable` or in `release` mode.", - Self::url() - ))), - None => Ok(()), - } + Self::register_in::() } } -/// Type for errors that can occur when using server functions. -#[derive(Error, Debug, Clone, Serialize, Deserialize)] -pub enum ServerFnError { - /// Error while trying to register the server function (only occurs in case of poisoned RwLock). - #[error("error while trying to register the server function: {0}")] - Registration(String), - /// Occurs on the client if there is a network error while trying to run function on server. - #[error("error reaching server to call server function: {0}")] - Request(String), - /// Occurs when there is an error while actually running the function on the server. - #[error("error running server function: {0}")] - ServerError(String), - /// Occurs on the client if there is an error deserializing the server's response. - #[error("error deserializing server function results {0}")] - Deserialization(String), - /// Occurs on the client if there is an error serializing the server function arguments. - #[error("error serializing server function results {0}")] - Serialization(String), - /// Occurs on the server if there is an error deserializing one of the arguments that's been sent. - #[error("error deserializing server function arguments {0}")] - Args(String), - /// Occurs on the server if there's a missing argument. - #[error("missing argument {0}")] - MissingArg(String), -} - -/// Executes the HTTP call to call a server function from the client, given its URL and argument type. -#[cfg(not(feature = "ssr"))] -pub async fn call_server_fn( - url: &str, - args: impl ServerFn, - enc: Encoding, -) -> Result -where - T: serde::Serialize + serde::de::DeserializeOwned + Sized, -{ - use ciborium::ser::into_writer; - use js_sys::Uint8Array; - use serde_json::Deserializer as JSONDeserializer; - - #[derive(Debug)] - enum Payload { - Binary(Vec), - Url(String), - } - let args_encoded = match &enc { - Encoding::Url => Payload::Url( - serde_urlencoded::to_string(&args) - .map_err(|e| ServerFnError::Serialization(e.to_string()))?, - ), - Encoding::Cbor => { - let mut buffer: Vec = Vec::new(); - into_writer(&args, &mut buffer) - .map_err(|e| ServerFnError::Serialization(e.to_string()))?; - Payload::Binary(buffer) - } - }; - - let content_type_header = match &enc { - Encoding::Url => "application/x-www-form-urlencoded", - Encoding::Cbor => "application/cbor", - }; - - let accept_header = match &enc { - Encoding::Url => "application/x-www-form-urlencoded", - Encoding::Cbor => "application/cbor", - }; - - let resp = match args_encoded { - Payload::Binary(b) => { - let slice_ref: &[u8] = &b; - let js_array = Uint8Array::from(slice_ref).buffer(); - gloo_net::http::Request::post(url) - .header("Content-Type", content_type_header) - .header("Accept", accept_header) - .body(js_array) - .send() - .await - .map_err(|e| ServerFnError::Request(e.to_string()))? - } - Payload::Url(s) => gloo_net::http::Request::post(url) - .header("Content-Type", content_type_header) - .header("Accept", accept_header) - .body(s) - .send() - .await - .map_err(|e| ServerFnError::Request(e.to_string()))?, - }; - - // check for error status - let status = resp.status(); - if (500..=599).contains(&status) { - return Err(ServerFnError::ServerError(resp.status_text())); - } - - if enc == Encoding::Cbor { - let binary = resp - .binary() - .await - .map_err(|e| ServerFnError::Deserialization(e.to_string()))?; - - ciborium::de::from_reader(binary.as_slice()) - .map_err(|e| ServerFnError::Deserialization(e.to_string())) - } else { - let text = resp - .text() - .await - .map_err(|e| ServerFnError::Deserialization(e.to_string()))?; - - let mut deserializer = JSONDeserializer::from_str(&text); - T::deserialize(&mut deserializer) - .map_err(|e| ServerFnError::Deserialization(e.to_string())) - } -} +impl ServerFn for T where T: server_fn::ServerFn {} diff --git a/server_fn/Cargo.toml b/server_fn/Cargo.toml new file mode 100644 index 000000000..ecf73db46 --- /dev/null +++ b/server_fn/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "server_fn" +version = { workspace = true } +edition = "2021" +authors = ["Greg Johnston"] +license = "MIT" +repository = "https://github.com/leptos-rs/leptos" +description = "RPC for any web framework." +readme = "../README.md" + +[dependencies] +form_urlencoded = "1" +gloo-net = "0.2" +js-sys = "0.3" +lazy_static = "1" +serde = { version = "1", features = ["derive"] } +serde_urlencoded = "0.7" +thiserror = "1" +serde_json = "1.0.89" +quote = "1" +syn = { version = "1", features = ["full", "parsing", "extra-traits"] } +proc-macro2 = "1" +cfg-if = "1" +ciborium = "0.2.0" +xxhash-rust = { version = "0.8.6", features = ["const_xxh64"] } +const_format = "0.2.30" +server_fn_macro_default = { path = "./server_fn_macro_default" } + +[features] +ssr = [] diff --git a/server_fn/server_fn_macro_default/Cargo.toml b/server_fn/server_fn_macro_default/Cargo.toml new file mode 100644 index 000000000..e72375756 --- /dev/null +++ b/server_fn/server_fn_macro_default/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "server_fn_macro_default" +version = { workspace = true } +edition = "2021" +authors = ["Greg Johnston"] +license = "MIT" +repository = "https://github.com/leptos-rs/leptos" +description = "The default implementation of the server_fn macro without a context" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +syn = { version = "1", features = ["full"] } +server_fn_macro = { workspace = true } + +[dev-dependencies] +server_fn = { workspace = true } +serde = "1" \ No newline at end of file diff --git a/server_fn/server_fn_macro_default/src/lib.rs b/server_fn/server_fn_macro_default/src/lib.rs new file mode 100644 index 000000000..f796a8f0c --- /dev/null +++ b/server_fn/server_fn_macro_default/src/lib.rs @@ -0,0 +1,63 @@ +#![cfg_attr(not(feature = "stable"), feature(proc_macro_span))] +//! This crate contains the default implementation of the #[macro@crate::server] macro without a context from the server. See the [server_fn_macro] crate for more information. +#![forbid(unsafe_code)] + +use proc_macro::TokenStream; +use server_fn_macro::server_macro_impl; +use syn::__private::ToTokens; + +/// Declares that a function is a [server function](server_fn). This means that +/// its body will only run on the server, i.e., when the `ssr` feature is enabled. +/// +/// You can specify one, two, or three arguments to the server function: +/// 1. **Required**: A type name that will be used to identify and register the server function +/// (e.g., `MyServerFn`). +/// 2. *Optional*: A URL prefix at which the function will be mounted when it’s registered +/// (e.g., `"/api"`). Defaults to `"/"`. +/// 3. *Optional*: either `"Cbor"` (specifying that it should use the binary `cbor` format for +/// serialization) or `"Url"` (specifying that it should be use a URL-encoded form-data string). +/// Defaults to `"Url"`. If you want to use this server function to power a `
` that will +/// work without WebAssembly, the encoding must be `"Url"`. +/// +/// The server function itself can take any number of arguments, each of which should be serializable +/// and deserializable with `serde`. +/// +/// ```ignore +/// # use server_fn::*; use serde::{Serialize, Deserialize}; +/// # #[derive(Serialize, Deserialize)] +/// # pub struct Post { } +/// #[server(ReadPosts, "/api")] +/// pub async fn read_posts(how_many: u8, query: String) -> Result, ServerFnError> { +/// // do some work on the server to access the database +/// todo!() +/// } +/// ``` +/// +/// Note the following: +/// - You must **register** the server function by calling `T::register()` somewhere in your main function. +/// - **Server functions must be `async`.** Even if the work being done inside the function body +/// can run synchronously on the server, from the client’s perspective it involves an asynchronous +/// function call. +/// - **Server functions must return `Result`.** Even if the work being done +/// inside the function body can’t fail, the processes of serialization/deserialization and the +/// network call are fallible. +/// - **Return types must implement [Serialize](serde::Serialize).** +/// This should be fairly obvious: we have to serialize arguments to send them to the server, and we +/// need to deserialize the result to return it to the client. +/// - **Arguments must be implement [`Serialize`](https://docs.rs/serde/latest/serde/trait.Serialize.html) +/// and [`DeserializeOwned`](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html).** +/// They are serialized as an `application/x-www-form-urlencoded` +/// form data using [`serde_urlencoded`](https://docs.rs/serde_urlencoded/latest/serde_urlencoded/) or as `application/cbor` +/// using [`cbor`](https://docs.rs/cbor/latest/cbor/). +#[proc_macro_attribute] +pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream { + match server_macro_impl( + args.into(), + s.into(), + None, + Some(syn::parse_quote!(server_fn)), + ) { + Err(e) => e.to_compile_error().into(), + Ok(s) => s.to_token_stream().into(), + } +} diff --git a/server_fn/src/lib.rs b/server_fn/src/lib.rs new file mode 100644 index 000000000..bd22bfd64 --- /dev/null +++ b/server_fn/src/lib.rs @@ -0,0 +1,432 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +//! # Server Functions +//! +//! This package is based on a simple idea: sometimes it’s useful to write functions +//! that will only run on the server, and call them from the client. +//! +//! If you’re creating anything beyond a toy app, you’ll need to do this all the time: +//! reading from or writing to a database that only runs on the server, running expensive +//! computations using libraries you don’t want to ship down to the client, accessing +//! APIs that need to be called from the server rather than the client for CORS reasons +//! or because you need a secret API key that’s stored on the server and definitely +//! shouldn’t be shipped down to a user’s browser. +//! +//! Traditionally, this is done by separating your server and client code, and by setting +//! up something like a REST API or GraphQL API to allow your client to fetch and mutate +//! data on the server. This is fine, but it requires you to write and maintain your code +//! in multiple separate places (client-side code for fetching, server-side functions to run), +//! as well as creating a third thing to manage, which is the API contract between the two. +//! +//! This package provides two simple primitives that allow you instead to write co-located, +//! isomorphic server functions. (*Co-located* means you can write them in your app code so +//! that they are “located alongside” the client code that calls them, rather than separating +//! the client and server sides. *Isomorphic* means you can call them from the client as if +//! you were simply calling a function; the function call has the “same shape” on the client +//! as it does on the server.) +//! +//! ### `#[server]` +//! +//! The [`#[server]`](https://docs.rs/server_fn/latest/server_fn/attr.server.html) macro allows you to annotate a function to +//! indicate that it should only run on the server (i.e., when you have an `ssr` feature in your +//! crate that is enabled). +//! +//! **Important**: All server functions must be registered by calling [ServerFn::register_in] +//! somewhere within your `main` function. +//! +//! ```rust,ignore +//! #[server(ReadFromDB)] +//! async fn read_posts(how_many: usize, query: String) -> Result, ServerFnError> { +//! // do some server-only work here to access the database +//! let posts = ...; +//! Ok(posts) +//! } +//! +//! // call the function +//! # #[tokio::main] +//! # async fn main() { +//! async { +//! let posts = read_posts(3, "my search".to_string()).await; +//! log::debug!("posts = {posts{:#?}"); +//! } +//! # } +//! +//! // make sure you've registered it somewhere in main +//! fn main() { +//! _ = ReadFromDB::register(); +//! } +//! ``` +//! +//! If you call this function from the client, it will serialize the function arguments and `POST` +//! them to the server as if they were the inputs in ``. +//! +//! Here’s what you need to remember: +//! - **Server functions must be `async`.** Even if the work being done inside the function body +//! can run synchronously on the server, from the client’s perspective it involves an asynchronous +//! function call. +//! - **Server functions must return `Result`.** Even if the work being done +//! inside the function body can’t fail, the processes of serialization/deserialization and the +//! network call are fallible. +//! - **Return types must implement [Serialize](serde::Serialize).** +//! This should be fairly obvious: we have to serialize arguments to send them to the server, and we +//! need to deserialize the result to return it to the client. +//! - **Arguments must be implement [serde::Serialize].** They are serialized as an `application/x-www-form-urlencoded` +//! form data using [`serde_urlencoded`](https://docs.rs/serde_urlencoded/latest/serde_urlencoded/) or as `application/cbor` +//! using [`cbor`](https://docs.rs/cbor/latest/cbor/). + +// used by the macro +#[doc(hidden)] +pub use const_format; +use proc_macro2::TokenStream; +use quote::TokenStreamExt; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +pub use server_fn_macro_default::server; +#[cfg(any(feature = "ssr", doc))] +use std::sync::Arc; +use std::{future::Future, pin::Pin, str::FromStr}; +use syn::parse_quote; +use thiserror::Error; +// used by the macro +#[doc(hidden)] +pub use xxhash_rust; + +#[cfg(any(feature = "ssr", doc))] +/// Something that can register a server function. +pub trait ServerFunctionRegistry { + /// An error that can occur when registering a server function. + type Error: std::error::Error; + /// Registers a server function at the given URL. + fn register( + url: &'static str, + server_function: Arc>, + ) -> Result<(), Self::Error>; + /// Returns the server function registered at the given URL, or `None` if no function is registered at that URL. + fn get(url: &str) -> Option>>; + /// Returns a list of all registered server functions. + fn paths_registered() -> Vec<&'static str>; +} + +/// A server function that can be called from the client. +pub type ServerFnTraitObj = dyn Fn( + T, + &[u8], + ) -> Pin>>> + + Send + + Sync; + +/// A dual type to hold the possible Response datatypes +#[derive(Debug)] +pub enum Payload { + ///Encodes Data using CBOR + Binary(Vec), + ///Encodes data in the URL + Url(String), + ///Encodes Data using Json + Json(String), +} + +/// Attempts to find a server function registered at the given path. +/// +/// This can be used by a server to handle the requests, as in the following example (using `actix-web`) +/// +/// ```rust, ignore +/// #[post("{tail:.*}")] +/// async fn handle_server_fns( +/// req: HttpRequest, +/// params: web::Path, +/// body: web::Bytes, +/// ) -> impl Responder { +/// let path = params.into_inner(); +/// let accept_header = req +/// .headers() +/// .get("Accept") +/// .and_then(|value| value.to_str().ok()); +/// +/// if let Some(server_fn) = server_fn_by_path::(path.as_str()) { +/// let body: &[u8] = &body; +/// match server_fn(&body).await { +/// Ok(serialized) => { +/// // if this is Accept: application/json then send a serialized JSON response +/// if let Some("application/json") = accept_header { +/// HttpResponse::Ok().body(serialized) +/// } +/// // otherwise, it's probably a submit or something: redirect back to the referrer +/// else { +/// HttpResponse::SeeOther() +/// .insert_header(("Location", "/")) +/// .content_type("application/json") +/// .body(serialized) +/// } +/// } +/// Err(e) => { +/// eprintln!("server function error: {e:#?}"); +/// HttpResponse::InternalServerError().body(e.to_string()) +/// } +/// } +/// } else { +/// HttpResponse::BadRequest().body(format!("Could not find a server function at that route.")) +/// } +/// } +/// ``` +#[cfg(any(feature = "ssr", doc))] +pub fn server_fn_by_path>( + path: &str, +) -> Option>> { + R::get(path) +} + +/// Returns the set of currently-registered server function paths, for debugging purposes. +#[cfg(any(feature = "ssr", doc))] +pub fn server_fns_by_path>( +) -> Vec<&'static str> { + R::paths_registered() +} + +/// Holds the current options for encoding types. +/// More could be added, but they need to be serde +#[derive(Debug, PartialEq)] +pub enum Encoding { + /// A Binary Encoding Scheme Called Cbor + Cbor, + /// The Default URL-encoded encoding method + Url, +} + +impl FromStr for Encoding { + type Err = (); + + fn from_str(input: &str) -> Result { + match input { + "URL" => Ok(Encoding::Url), + "Cbor" => Ok(Encoding::Cbor), + _ => Err(()), + } + } +} + +impl quote::ToTokens for Encoding { + fn to_tokens(&self, tokens: &mut TokenStream) { + let option: syn::Ident = match *self { + Encoding::Cbor => parse_quote!(Cbor), + Encoding::Url => parse_quote!(Url), + }; + let expansion: syn::Ident = syn::parse_quote! { + Encoding::#option + }; + tokens.append(expansion); + } +} + +/// Defines a "server function." A server function can be called from the server or the client, +/// but the body of its code will only be run on the server, i.e., if a crate feature `ssr` (server-side-rendering) is enabled. +/// +/// Server functions are created using the `server` macro. +/// +/// The function should be registered by calling `ServerFn::register()`. The set of server functions +/// can be queried on the server for routing purposes by calling [server_fn_by_path]. +/// +/// Technically, the trait is implemented on a type that describes the server function's arguments. +pub trait ServerFn +where + Self: Serialize + DeserializeOwned + Sized + 'static, +{ + /// The return type of the function. + type Output: Serialize; + + /// URL prefix that should be prepended by the client to the generated URL. + fn prefix() -> &'static str; + + /// The path at which the server function can be reached on the server. + fn url() -> &'static str; + + /// The path at which the server function can be reached on the server. + fn encoding() -> Encoding; + + /// Runs the function on the server. + #[cfg(any(feature = "ssr", doc))] + fn call_fn( + self, + cx: T, + ) -> Pin>>>; + + /// Runs the function on the client by sending an HTTP request to the server. + #[cfg(any(not(feature = "ssr"), doc))] + fn call_fn_client( + self, + cx: T, + ) -> Pin>>>; + + /// Registers the server function, allowing the server to query it by URL. + #[cfg(any(feature = "ssr", doc))] + fn register_in>() -> Result<(), ServerFnError> + { + // create the handler for this server function + // takes a String -> returns its async value + + let run_server_fn = Arc::new(|cx: T, data: &[u8]| { + // decode the args + let value = match Self::encoding() { + Encoding::Url => serde_urlencoded::from_bytes(data) + .map_err(|e| ServerFnError::Deserialization(e.to_string())), + Encoding::Cbor => ciborium::de::from_reader(data) + .map_err(|e| ServerFnError::Deserialization(e.to_string())), + }; + Box::pin(async move { + let value: Self = match value { + Ok(v) => v, + Err(e) => return Err(e), + }; + + // call the function + let result = match value.call_fn(cx).await { + Ok(r) => r, + Err(e) => return Err(e), + }; + + // serialize the output + let result = match Self::encoding() { + Encoding::Url => match serde_json::to_string(&result) + .map_err(|e| { + ServerFnError::Serialization(e.to_string()) + }) { + Ok(r) => Payload::Url(r), + Err(e) => return Err(e), + }, + Encoding::Cbor => { + let mut buffer: Vec = Vec::new(); + match ciborium::ser::into_writer(&result, &mut buffer) + .map_err(|e| { + ServerFnError::Serialization(e.to_string()) + }) { + Ok(_) => Payload::Binary(buffer), + Err(e) => return Err(e), + } + } + }; + + Ok(result) + }) + as Pin>>> + }); + + // store it in the hashmap + R::register(Self::url(), run_server_fn) + .map_err(|e| ServerFnError::Registration(e.to_string())) + } +} + +/// Type for errors that can occur when using server functions. +#[derive(Error, Debug, Clone, Serialize, Deserialize)] +pub enum ServerFnError { + /// Error while trying to register the server function (only occurs in case of poisoned RwLock). + #[error("error while trying to register the server function: {0}")] + Registration(String), + /// Occurs on the client if there is a network error while trying to run function on server. + #[error("error reaching server to call server function: {0}")] + Request(String), + /// Occurs when there is an error while actually running the function on the server. + #[error("error running server function: {0}")] + ServerError(String), + /// Occurs on the client if there is an error deserializing the server's response. + #[error("error deserializing server function results {0}")] + Deserialization(String), + /// Occurs on the client if there is an error serializing the server function arguments. + #[error("error serializing server function results {0}")] + Serialization(String), + /// Occurs on the server if there is an error deserializing one of the arguments that's been sent. + #[error("error deserializing server function arguments {0}")] + Args(String), + /// Occurs on the server if there's a missing argument. + #[error("missing argument {0}")] + MissingArg(String), +} + +/// Executes the HTTP call to call a server function from the client, given its URL and argument type. +#[cfg(not(feature = "ssr"))] +pub async fn call_server_fn( + url: &str, + args: impl ServerFn, + enc: Encoding, +) -> Result +where + T: serde::Serialize + serde::de::DeserializeOwned + Sized, +{ + use ciborium::ser::into_writer; + use js_sys::Uint8Array; + use serde_json::Deserializer as JSONDeserializer; + + #[derive(Debug)] + enum Payload { + Binary(Vec), + Url(String), + } + let args_encoded = match &enc { + Encoding::Url => Payload::Url( + serde_urlencoded::to_string(&args) + .map_err(|e| ServerFnError::Serialization(e.to_string()))?, + ), + Encoding::Cbor => { + let mut buffer: Vec = Vec::new(); + into_writer(&args, &mut buffer) + .map_err(|e| ServerFnError::Serialization(e.to_string()))?; + Payload::Binary(buffer) + } + }; + + let content_type_header = match &enc { + Encoding::Url => "application/x-www-form-urlencoded", + Encoding::Cbor => "application/cbor", + }; + + let accept_header = match &enc { + Encoding::Url => "application/x-www-form-urlencoded", + Encoding::Cbor => "application/cbor", + }; + + let resp = match args_encoded { + Payload::Binary(b) => { + let slice_ref: &[u8] = &b; + let js_array = Uint8Array::from(slice_ref).buffer(); + gloo_net::http::Request::post(url) + .header("Content-Type", content_type_header) + .header("Accept", accept_header) + .body(js_array) + .send() + .await + .map_err(|e| ServerFnError::Request(e.to_string()))? + } + Payload::Url(s) => gloo_net::http::Request::post(url) + .header("Content-Type", content_type_header) + .header("Accept", accept_header) + .body(s) + .send() + .await + .map_err(|e| ServerFnError::Request(e.to_string()))?, + }; + + // check for error status + let status = resp.status(); + if (500..=599).contains(&status) { + return Err(ServerFnError::ServerError(resp.status_text())); + } + + if enc == Encoding::Cbor { + let binary = resp + .binary() + .await + .map_err(|e| ServerFnError::Deserialization(e.to_string()))?; + + ciborium::de::from_reader(binary.as_slice()) + .map_err(|e| ServerFnError::Deserialization(e.to_string())) + } else { + let text = resp + .text() + .await + .map_err(|e| ServerFnError::Deserialization(e.to_string()))?; + + let mut deserializer = JSONDeserializer::from_str(&text); + T::deserialize(&mut deserializer) + .map_err(|e| ServerFnError::Deserialization(e.to_string())) + } +} diff --git a/server_fn_macro/Cargo.toml b/server_fn_macro/Cargo.toml new file mode 100644 index 000000000..36fb70bad --- /dev/null +++ b/server_fn_macro/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "server_fn_macro" +version = { workspace = true } +edition = "2021" +authors = ["Greg Johnston"] +license = "MIT" +repository = "https://github.com/leptos-rs/leptos" +description = "RPC for any web framework." +readme = "../README.md" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_urlencoded = "0.7" +serde_json = "1.0.89" +quote = "1" +syn = { version = "1", features = ["full", "parsing", "extra-traits"] } +proc-macro2 = "1" +proc-macro-error = "1" +xxhash-rust = { version = "0.8.6", features = ["const_xxh64"] } +const_format = "0.2.30" diff --git a/leptos_macro/src/server.rs b/server_fn_macro/src/lib.rs similarity index 53% rename from leptos_macro/src/server.rs rename to server_fn_macro/src/lib.rs index 23d3ad492..f5377ed59 100644 --- a/leptos_macro/src/server.rs +++ b/server_fn_macro/src/lib.rs @@ -1,6 +1,12 @@ -use cfg_if::cfg_if; -use leptos_server::Encoding; +#![cfg_attr(not(feature = "stable"), feature(proc_macro_span))] +#![forbid(unsafe_code)] +#![deny(missing_docs)] +//! Implementation of the server_fn macro. +//! +//! This crate contains the implementation of the server_fn macro. [server_macro_impl] can be used to implement custom versions of the macro for different frameworks that allow users to pass a custom context from the server to the server function. + use proc_macro2::{Literal, TokenStream as TokenStream2}; +use proc_macro_error::abort; use quote::quote; use syn::{ parse::{Parse, ParseStream}, @@ -8,13 +14,22 @@ use syn::{ *, }; -fn fn_arg_is_cx(f: &syn::FnArg) -> bool { +/// Discribes the custom context from the server that passed to the server function. Optionally, the first argument of a server function +/// can be a custom context of this type. This context can be used to access the server's state within the server function. +pub struct ServerContext { + /// The type of the context. + pub ty: Ident, + /// The path to the context type. Used to reference the context type in the generated code. + pub path: Path, +} + +fn fn_arg_is_cx(f: &syn::FnArg, server_context: &ServerContext) -> bool { if let FnArg::Typed(t) = f { if let Type::Path(path) = &*t.ty { path.path .segments .iter() - .any(|segment| segment.ident == "Scope") + .any(|segment| segment.ident == server_context.ty) } else { false } @@ -23,57 +38,81 @@ fn fn_arg_is_cx(f: &syn::FnArg) -> bool { } } +/// The implementation of the server_fn macro. +/// To allow the macro to accept a custom context from the server, pass a custom server context to this function. +/// **The Context comes from the server.** Optionally, the first argument of a server function +/// can be a custom context. This context can be used to inject dependencies like the HTTP request +/// or response or other server-only dependencies, but it does *not* have access to state that exists in the client. +/// +/// The paths passed into this function are used in the generated code, so they must be in scope when the macro is called. +/// +/// ```ignore +/// #[proc_macro_attribute] +/// pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream { +/// let server_context = Some(ServerContext { +/// ty: syn::parse_quote!(MyContext), +/// path: syn::parse_quote!(my_crate::prelude::MyContext), +/// }); +/// match server_macro_impl( +/// args.into(), +/// s.into(), +/// Some(server_context), +/// Some(syn::parse_quote!(my_crate::exports::server_fn)), +/// ) { +/// Err(e) => e.to_compile_error().into(), +/// Ok(s) => s.to_token_stream().into(), +/// } +/// } +/// ``` + pub fn server_macro_impl( - args: proc_macro::TokenStream, - s: TokenStream2, + args: TokenStream2, + body: TokenStream2, + server_context: Option, + server_fn_path: Option, ) -> Result { let ServerFnName { struct_name, prefix, encoding, .. - } = syn::parse::(args)?; + } = syn::parse2::(args)?; let prefix = prefix.unwrap_or_else(|| Literal::string("")); - let encoding = match encoding { - Encoding::Cbor => quote! { ::leptos::leptos_server::Encoding::Cbor }, - Encoding::Url => quote! { ::leptos::leptos_server::Encoding::Url }, - }; + let encoding = quote!(#server_fn_path::#encoding); - let body = syn::parse::(s.into())?; + let body = syn::parse::(body.into())?; let fn_name = &body.ident; let fn_name_as_str = body.ident.to_string(); let vis = body.vis; let block = body.block; - cfg_if! { - if #[cfg(all(not(feature = "stable"), debug_assertions))] { - use proc_macro::Span; - let span = Span::call_site(); - #[cfg(not(target_os = "windows"))] - let url = format!("{}/{}", span.source_file().path().to_string_lossy(), fn_name_as_str).replace('/', "-"); - #[cfg(target_os = "windows")] - let url = format!("{}\\{}", span.source_file().path().to_string_lossy(), fn_name_as_str).replace("\\", "-"); - } else { - let url = fn_name_as_str; - } - } - - let fields = body.inputs.iter().filter(|f| !fn_arg_is_cx(f)).map(|f| { - let typed_arg = match f { - FnArg::Receiver(_) => { - abort!(f, "cannot use receiver types in server function macro") + let fields = body + .inputs + .iter() + .filter(|f| { + if let Some(ctx) = &server_context { + !fn_arg_is_cx(f, ctx) + } else { + true } - FnArg::Typed(t) => t, - }; - quote! { pub #typed_arg } - }); + }) + .map(|f| { + let typed_arg = match f { + FnArg::Receiver(_) => { + abort!( + f, + "cannot use receiver types in server function macro" + ) + } + FnArg::Typed(t) => t, + }; + quote! { pub #typed_arg } + }); let cx_arg = body.inputs.iter().next().and_then(|f| { - if fn_arg_is_cx(f) { - Some(f) - } else { - None - } + server_context + .as_ref() + .and_then(|ctx| fn_arg_is_cx(f, ctx).then_some(f)) }); let cx_assign_statement = if let Some(FnArg::Typed(arg)) = cx_arg { if let Pat::Ident(id) = &*arg.pat { @@ -100,7 +139,11 @@ pub fn server_macro_impl( } FnArg::Typed(t) => t, }; - let is_cx = fn_arg_is_cx(f); + let is_cx = if let Some(ctx) = &server_context { + !fn_arg_is_cx(f, ctx) + } else { + true + }; if is_cx { quote! { #[allow(unused)] @@ -115,8 +158,12 @@ pub fn server_macro_impl( let field_names = body.inputs.iter().filter_map(|f| match f { FnArg::Receiver(_) => todo!(), FnArg::Typed(t) => { - if fn_arg_is_cx(f) { - None + if let Some(ctx) = &server_context { + if fn_arg_is_cx(f, ctx) { + None + } else { + Some(&t.pat) + } } else { Some(&t.pat) } @@ -148,13 +195,24 @@ pub fn server_macro_impl( ); }; + let server_ctx_path = if let Some(ctx) = &server_context { + let path = &ctx.path; + quote!(#path) + } else { + quote!(()) + }; + + let server_fn_path = server_fn_path + .map(|path| quote!(#path)) + .unwrap_or_else(|| quote! { server_fn }); + Ok(quote::quote! { #[derive(Clone, Debug, ::serde::Serialize, ::serde::Deserialize)] pub struct #struct_name { #(#fields),* } - impl leptos::ServerFn for #struct_name { + impl #server_fn_path::ServerFn<#server_ctx_path> for #struct_name { type Output = #output_ty; fn prefix() -> &'static str { @@ -162,22 +220,22 @@ pub fn server_macro_impl( } fn url() -> &'static str { - #url + #server_fn_path::const_format::concatcp!(#fn_name_as_str, #server_fn_path::xxhash_rust::const_xxh64::xxh64(concat!(env!("CARGO_MANIFEST_DIR"), ":", file!(), ":", line!(), ":", column!()).as_bytes(), 0)) } - fn encoding() -> ::leptos::leptos_server::Encoding { + fn encoding() -> #server_fn_path::Encoding { #encoding } #[cfg(feature = "ssr")] - fn call_fn(self, cx: ::leptos::Scope) -> std::pin::Pin>>> { + fn call_fn(self, cx: #server_ctx_path) -> std::pin::Pin>>> { let #struct_name { #(#field_names),* } = self; #cx_assign_statement; Box::pin(async move { #fn_name( #cx_fn_arg #(#field_names_2),*).await }) } #[cfg(not(feature = "ssr"))] - fn call_fn_client(self, cx: ::leptos::Scope) -> std::pin::Pin>>> { + fn call_fn_client(self, cx: #server_ctx_path) -> std::pin::Pin>>> { let #struct_name { #(#field_names_3),* } = self; Box::pin(async move { #fn_name( #cx_fn_arg #(#field_names_4),*).await }) } @@ -187,21 +245,22 @@ pub fn server_macro_impl( #vis async fn #fn_name(#(#fn_args),*) #output_arrow #return_ty { #block } + #[cfg(not(feature = "ssr"))] #vis async fn #fn_name(#(#fn_args_2),*) #output_arrow #return_ty { let prefix = #struct_name::prefix().to_string(); let url = prefix + "/" + #struct_name::url(); - ::leptos::leptos_server::call_server_fn(&url, #struct_name { #(#field_names_5),* }, #encoding).await + #server_fn_path::call_server_fn(&url, #struct_name { #(#field_names_5),* }, #encoding).await } }) } -pub struct ServerFnName { +struct ServerFnName { struct_name: Ident, _comma: Option, prefix: Option, _comma2: Option, - encoding: Encoding, + encoding: Path, } impl Parse for ServerFnName { @@ -210,7 +269,14 @@ impl Parse for ServerFnName { let _comma = input.parse()?; let prefix = input.parse()?; let _comma2 = input.parse()?; - let encoding = input.parse().unwrap_or(Encoding::Url); + let encoding = input + .parse::() + .map(|encoding| match encoding.to_string().as_str() { + "\"Url\"" => syn::parse_quote!(Encoding::Url), + "\"Cbor\"" => syn::parse_quote!(Encoding::Cbor), + _ => abort!(encoding, "Encoding Not Found"), + }) + .unwrap_or(syn::parse_quote!(Encoding::Url)); Ok(Self { struct_name, @@ -222,7 +288,8 @@ impl Parse for ServerFnName { } } -pub struct ServerFnBody { +#[allow(unused)] +struct ServerFnBody { pub attrs: Vec, pub vis: syn::Visibility, pub async_token: Token![async],